Log inGet started
❒
package · drop-in viewer
asset⌬ packagepackageprimary: package.yaml·originates fromworld 07158574-5…

terrainSystem

No description provided.

byzero-proxy @ DESKTOP-DB3UJOJ·posted 3mo ago
What it does

No description provided yet.

Interface

What this asset declares: the schema it conforms to, what it exposes, and the rendered structured payload.

This asset doesn't declare a schema or structured payload.

Sub-parts

Everything contained inside this part. Assets are composite children (clickable cards). Files are leaf payloads. Expand any row to view its source.

Showing the 199 items nearest this part. It holds more than this page lists.

199+items
·
assettype · born here
❒asset
# terrainData A landscape's baked data, as one asset. A terrain is never a single image. It is a heightfield, the control textures saying which surface covers where, a mask of where there is no ground at all, the water surface standing over it, and the table of what those surfaces are. Every one of those is an ordinary `.texture` the renderer already knows how to sample; this container holds them together and names their roles, so a `Terrain` component references a **landscape** rather than a handful of loose images, and a heightfield never turns up in the picker beside an albedo map. ``` dunes.terrainData/ terrain.yaml the manifest: where the ground stands, and role -> child height.texture/ rgba32f: R metres, G/B gradient, A coverage holes.texture/ rgba8: R non-zero where there IS ground water.texture/ rgba32f: R the surface's world Y, G its coverage water_shade.texture/ rgba8: R the water column over the deepest column, G coverage control0.texture/ rgba8: four layer weights, one per channel control1.texture/ rgba8: layers 5-8 slice_albedo_0.texture/ rgba8: one layer texture per distinct albedo slice_normal_0.texture/ rgba8: one per distinct normal map slice_mask_0.texture/ rgba8: one per distinct mask map ``` The children are written by the same `asset.create` every other texture goes through and placed inside the container by `dest`, the way a bundle carries the meshes it assembles. A container holds `holes`, `water` and the `slice_*` children only when the landscape has them. ## The manifest ```yaml version: 1 size: [4000, 4000] # metres of ground, along world X and Z origin: [0, 0] # world centre of that rect heightRange: [-8.1, 63.4] # the vertical reach the geometry declares height: height.texture holes: holes.texture water: water.texture waterShade: { file: water_shade.texture, depthMax: 12.5 } controls: [control0.texture, control1.texture] layers: - { name: sand, tint: [0.62, 0.56, 0.42], tiling: 12, albedoSlice: 0, normalSlice: 0, maskSlice: -1 } - { name: rock, tiling: 8, albedoSlice: 1, normalSlice: -1, maskSlice: -1 } slices: albedo: [slice_albedo_0.texture, slice_albedo_1.texture] normal: [slice_normal_0.texture] size: [1024, 1024] ``` Children are named **relative to the container**, so a landscape that is moved or renamed carries its own textures with it. ## The payloads **The heightfield** is `rgba32f`: R the height in **metres**, G and B its gradient along world X and Z, A coverage. Metres and not a normalized 16-bit ramp, because relief is often a few metres inside a range of hundreds and eight or sixteen bits band that into terraces. The gradient is what a surface normal comes out of without differencing the height four more times. **The control textures** are `rgba8`, one channel per surface layer, four layers a texture and another texture for every four after that. A texel holds how much of each layer covers it, and the weights are **normalized to sum to one**: a texel covered half by sand and half by rock reads 128/128, whatever the two masks happened to evaluate to, so a shader blends by them without renormalizing per pixel. Layer `n` is channel `((n - 1) % 4) + 1` of control texture `ceil(n / 4)`; `:layerSlot(name)` answers that for a named layer. **The holes mask** is `rgba8`, R non-zero where there **is** ground. Terrain a cave mouth or a quarry has been cut out of has holes, and a hole is absence rather than a height of zero. **The water surface** is `rgba32f`: R the world Y the surface stands at, in metres, G whether a surface covers that texel. A sea, a lake and a river are the same thing here: a level and where it reaches. `water_shade` is its filterable `rgba8` companion that the water's material reads per pixel, R the column over `depthMax` and G coverage. **The layer table** says what each control channel stands for: its tint, its albedo, its normal map, its mask map (R metallic, G ambient occlusion, B height, A smoothness), and the metres one repeat of them covers (`tiling`). **The layer slices** are the layers' own textures, one child per distinct texture per role (albedo, normal, mask), all resampled to one square edge (`slices.size`). A surface binds each role's slices as one texture array, and each layer's `albedoSlice`, `normalSlice` and `maskSlice` says which slice it reads (0-based, -1 for none). A slot per layer per map would be twenty-four sampled textures in one bind group, past what a fragment stage is guaranteed; three arrays cost three slots whatever the layer count, and every slice wraps on its own. ## Filling one `terrain.write` in a procgen graph writes the manifest and every child in one step, and answers the reference to the container. It takes: - `raster`: the ground; its `height` layer becomes the heightfield. - `name`: the container's identity, a bare identifier (`dunes` writes `dunes.terrainData`). `folder` or `dest` places it. - `layers`: the names of the raster layers holding each surface's coverage, in channel order, separated by commas (`"sand, rock"`). Coverage per surface is an ordinary mask layer written by `terrain.mask`. - `surfaces`: optional records of `{ name, tint?, albedo?, normal?, mask?, tiling? }` matched to a layer by `name`, saying what each surface is shaded with. - `holes` / `solid`: a mask layer marking where ground is cut out, or where it exists. - `height`, `water`, `waterMask`: the raster layers read for the heightfield and the water surface. - `resolution` and `layerResolution`: the texel size of the maps and the edge of one layer slice. A surface that names no texture is shaded by its `tint`. Writing the same name again re-bakes in place and keeps the guid, so every reference already pointing at that landscape stays resolved. ## Reading one | Method | | |---|---| | `:placement()` | `{ size, origin, heightRange }`: where the ground stands and how far it reaches. | | `:height()` | The heightfield texture. | | `:holes()` | The holes mask, or nil when the ground is solid everywhere. | | `:water()` | The water surface texture, or nil when the landscape is dry. | | `:waterShade()` | `{ texture, depthMax }`, or nil for a landscape carrying no water. | | `:control(i)` / `:controls()` | The weight textures, in channel order. | | `:layers()` | The surface table. | | `:slices()` | `{ albedo?, normal?, mask?, size }`: the layer textures per role, or nil when no layer carries a texture. | | `:layerSlot(name)` | Which control texture and channel a named layer is in. | | `:manifest()` | The whole thing, decoded. | | `:instantiate(target?, opts?)` | An entity carrying a `Terrain` component bound to this asset. | | `:setChild(role, file)` / `:setPlacement(p)` / `:setLayers(t)` / `:setSlices(s)` / `:setWaterShade(file, depthMax)` | What `terrain.write` fills it through. | The placement travels with the landscape, so a `Terrain` component standing on one does not re-state how big it is. ## Validation `asset.validate` (and the publish gate) refuse a container whose manifest does not decode, that does not say where its ground stands, that names a child which is not there, or that declares more layers than its control textures can carry. A landscape whose heightfield is missing is ground nothing can stand on, and that is worth catching before it is published rather than when an entity tries to load it. ## In the Inspector Selecting a terrain opens its **Terrain** section: its size, origin and height range, the heightfield's resolution once one is written, a table of its material layers (name, tiling, whether it has an albedo), and a link to each child texture (heightfield, holes, water, control maps). A terrain not yet sculpted says it has no child textures.
▲ 0↑ born
◇
component · born here
❒asset
# Terrain (component) Ground, as scene content. Part of the [terrainSystem package](../../readme.md) — every terrain entity in a scene carries exactly one of these. An entity carrying this component IS a landscape: a `.terrainData` standing over a rect of world, drawn through a quadtree of LOD nodes that refines under whichever camera is looking at it, with a collider the default avatar can walk. ```luau -- The whole of it: one reference. The landscape says how big it is. entity(id).component.add("Terrain", { terrain = dunes }) ``` ## Properties | Name | Type | Default | Description | |------|------|---------|-------------| | `terrain` | `AssetRef<terrainData>` | `nil` | The landscape this entity IS — the heightfield, the control textures, the holes mask and the layer table, held together as one asset. It states the rect it covers and the reach it declares. | | `material` | `AssetRef<material>` | `nil` | What the ground is shaded with. Falls back to the package's `terrainStandard`. | | `size` | `vec2` | `{ 0, 0 }` | Where the ground stands, when it should stand somewhere other than where the landscape says. Left at zero, the container's own placement is used. | | `origin` | `vec2` | `{ 0, 0 }` | World centre of that rect, same rule. | | `heightRange` | `vec2` | `{ 0, 0 }` | The vertical reach the geometry declares — `x` the lowest metre, `y` the highest. Left at zero, the landscape's own reach is used. | | `collision` | `boolean` | `true` | A static heightfield collider over the whole rect. | | `collisionResolution` | `number` | `257` | Collider samples along each axis. | `heightRange` is the mesh's bounding box, so a range smaller than the ground culls the terrain away at grazing angles and the symptom is nothing at all on screen. The collider is ONE grid over the whole rect, so `collisionResolution` is its spacing: 257 over four kilometres is a sample every sixteen metres, and raising it buys resolution at O(n²) cells to build. The heightfield's own texel count sets how finely the ground is described inside the rect; the landscape's `size` sets how much world that description covers. ## Where the landscape comes from A graph. It opens a rect of ground with `terrain.source`, shapes it with the terrain nodes (`fbm`, `cone`, `ridge`, `erode`, ...), writes the coverage of each surface as a mask layer with `terrain.mask`, and ends in `terrain.write`, which bakes the manifest and every child texture in one step and answers the reference to the `.terrainData`. That is the same move every other procgen family makes to persist what it made. The reference is what the component's `terrain` property holds: `entity(id).component.add("Terrain", { terrain = ref })`. A heightmap made anywhere else (an 8-bit image, a downloaded elevation tile) comes in through `terrain.from_texture` and back out through `terrain.write`. ## Lifecycle - `awake` — opens the landscape, builds the LOD pool, binds it to the entity, adds the `Model` and the collider. - `update` / `editorUpdate` — follows the on-screen camera with the cut, in edit and in play alike. - `onPropertyChanged` — a `material` change swaps the Model's material in place; anything else describes the ground itself, so the ground is rebuilt. - `onDestroy` — releases the mesh, the pool and the heightfield's pixels. ## Methods | Method | | |---|---| | `:heightAt(x, z)` | Bilinear metres. | | `:normalAt(x, z)` | Unit surface normal, three numbers. | | `:slopeAt(x, z)` | Radians; 0 is flat. | | `:field()` | The heightfield itself — the value every `terrainHeightfield` verb takes. | | `:reload()` | Read `terrain` again and rebuild what the entity draws. | The queries answer from the same texels the geometry is built out of, so what a script is told and what a player walks on cannot disagree. Re-baking the landscape and calling `:reload()` moves every terrain standing on that asset, without a restart and without touching the scene graph — the mesh keeps its guid, so the Model is never re-bound. ## One draw Every node of the LOD cut is one range of one index buffer, so the whole terrain — at every level, however large — is a single indexed draw on a single entity. Nodes are never entities. `modules/terrainMesh` is the whole surface.
▲ 0↑ born
·
computeshader · born here
❒asset
# terrain_apply_delta Folds an erosion pass's accumulated fixed-point deltas into a channel and clears the accumulator. An erosion pass never writes height. Every gram of material it moves accumulates into an integer accumulator in units of 1/65536 m, because integer addition commutes and float addition does not — the summed result of thousands of droplets landing on one cell is the same whatever order the threads resolved in. This pass is where that sum becomes terrain. It runs once per erosion iteration, after the gather that brings in the material neighbouring tiles moved across the seam. `atomicExchange` reads and zeroes in one step, so the accumulator is empty for the next iteration without a second clearing pass. One thread per stored cell, apron included. An apron cell's own delta is applied here as well: the following apron exchange overwrites it wherever a neighbour owns the cell, and where the field ends and no neighbour does, the applied value is what keeps the border apron a plausible continuation of the terrain rather than a step.
▲ 0↑ born
·
computeshader · born here
❒asset
# terrain_apron Copies a rectangular block of a neighbour tile's stored cells into this tile. A tile's apron holds a second copy of cells its neighbour owns. An op that is a pure function of world position writes both copies to the same value on its own; an op whose result depends on what the tile already held — erosion above all — leaves the two copies disagreeing, and every neighbourhood read at the seam then sees a different surface depending on which tile it came through. This pass makes the apron read what the neighbour computed. The block is given in stored-cell coordinates (`dstX0, dstZ0`, `srcX0, srcZ0`, `blockWidth`, `blockHeight`) rather than as an edge index, so one shader moves an edge band and a corner square. The caller issues one dispatch per neighbour that exists: eight of them tile a tile's apron ring exactly, with no cell written twice. A block facing off the edge of the field has no neighbour and no dispatch. Its apron keeps whatever the generating op wrote there, which for a world-space op is the correct extrapolation of the landscape past the field boundary. Run it after any op whose result is not a pure function of world position, and between erosion batches — a droplet may walk at most `apron` cells before the neighbourhood it is reading is no longer the neighbourhood its neighbour has.
▲ 0↑ born
·
computeshader · born here
❒asset
# terrain_coast Pulls the ground outside a landmass down to a seabed altitude, by a radial falloff about a point. The land weight is 1 inside `radius`, falls to 0 across `falloff` metres beyond it, and the height is interpolated between the seabed and whatever the stack already built: ``` value = mix(seabed, prev, landWeight) ``` The interior keeps its landscape untouched, the rim slopes off it, and everything past the rim sits at the seabed. Put the seabed below the sea level and the result is an island — the terrain ends before the field's rect does, and the rect goes on carrying the sea. `shapeMode` picks the metric: 0 is a disc, 1 is the Chebyshev square. This op covers the **whole field**, not the disc it is centred on: the ground it changes is the ground outside that disc, and an op that only touched its own radius would leave the far corners standing above the water. It is an ordinary op in the stack, so an erosion below it weathers the coastline it drew, a flatten cuts a harbour into it, and a river carves a mouth through it. `terrain_coast_mask` is the same operation driven by a painted raster instead of a radius, for a coastline whose outline is drawn rather than described. Every op carries a mask. The op's weight at a cell is scaled by what the mask reads there — multiplied in, or subtracted out — so the op applies only where the mask lets it and a cell the mask holds out keeps exactly what it already held, at every blend mode. An op that names no mask writes at full strength everywhere.
▲ 0↑ born
·
computeshader · born here
❒asset
# terrain_coast_mask Pulls the ground outside a landmass down to a seabed altitude, with the landmass given by a painted raster rather than a radius. The raster is a layer co-registered with the field — one texel is one cell — so the outline an author paints is the outline the coast follows. The value is the land weight directly: ``` value = mix(seabed, prev, clamp(mask * gain, 0, 1)) ``` 1 is land and keeps the landscape untouched, 0 is open water and sits at the seabed, and the values between are the shoreline slope: the softness of the brush is the softness of the coast. `gain` scales the raster before the clamp, so a stroke painted at a fraction of full strength can still read as solid land. Like the radial form, this covers the **whole field** — the ground it changes is the ground the mask does not cover. `terrain_coast` is the same operation driven by a radius, for a coastline described rather than drawn. Every op carries a mask. The op's weight at a cell is scaled by what the mask reads there — multiplied in, or subtracted out — so the op applies only where the mask lets it and a cell the mask holds out keeps exactly what it already held, at every blend mode. An op that names no mask writes at full strength everywhere.
▲ 0↑ born
·
computeshader · born here
❒asset
# terrain_delta_gather Adds a neighbour tile's apron-ring erosion deltas into this tile's interior. A droplet spawned in one tile's interior may walk into that tile's apron and deposit there. Those apron cells belong to the neighbour — the neighbour's interior is what carries the field's value for them — so the deposit has to reach the neighbour's accumulator before either tile folds its accumulator into height. Without this pass, every cell within a droplet's reach of a seam collects less material than the same cell away from one, and the seam shows as a band running through the eroded terrain. The block is described in stored-cell coordinates, the same description `terrain_apron` takes, so the two passes address the same geometry from opposite ends: this one reads the neighbour's apron ring and adds into this tile's interior band; `terrain_apron` reads the neighbour's interior and overwrites this tile's apron ring. The accumulation is a fixed-point integer add. Integer addition commutes, so a corner cell reached from three neighbours at once sums to the same value whichever dispatch lands first — which is what keeps a tiled erosion run reproducible.
▲ 0↑ born
·
computeshader · born here
❒asset
# terrain_erode_droplet Droplet hydraulic erosion over one tile, one batch at a time. Each thread carries one droplet. It is placed by a hash of its index, the op's seed, the batch number and the tile's field-wide base cell, then runs downhill: it picks up material where it moves fast over steep ground and drops it where the ground flattens or rises. That is what cuts channels into a ridge and lays the fans out below them, and it is why the result looks like water shaped it rather than like a filter ran over it. ## Reproducibility The pass never writes height. Every change accumulates into `delta` as fixed-point integers, 1/65536 m per unit, through `atomicAdd`. Integer addition commutes; float addition does not. Thousands of droplets landing on one cell therefore sum to the same total whatever order the threads resolve in, and the same seed over the same stack gives the same terrain every run — on any GPU, with any scheduling. A float accumulator would be a few last bits different each run, and everything downstream that assumes a seed names a landscape (storage, streaming, a shared world) would drift. ## What one batch may move A batch works out every droplet's change from the ground as it stood when the batch began and adds them all at the end, so nothing in a batch can see what the rest of it is doing to the cell it is standing on. Two bounds are what keep that from running away. `share` is the part of one cell's rainfall a droplet carries. A batch rains `dropletCount` droplets over the interior and each walks up to `apron` cells, so a cell is stepped on `dropletCount * apron / interiorCells` times a batch and every one of those steps adds into the same accumulator. Scaling each droplet's change by the reciprocal is what makes `rain` a sampling density: doubling it resolves the flow better and moves the same material. Rain too sparse to step on every cell gives a droplet one cell's rainfall and no more, because a droplet carrying several cells' worth would put a whole batch's cut into a single step and reopen the swing below. A run below that density — fewer than `interiorCells / apron` droplets a batch — therefore samples the flow sparsely and moves proportionally less; above it the result stops changing with `rain`. `MAX_SHARE` is the most of the drop below a cell one batch may cut, and the most of the rise above it one batch may fill. Without it a droplet cuts the whole drop, which leaves the cell level with the one it drains into — and every other droplet in the batch cuts that same drop from the same starting ground, so the cell ends the batch far below its neighbour. The next batch finds the pair reversed and cuts back further, and the swing grows every batch until the fixed-point accumulator wraps. `terrain_erode_thermal` names the same bound; a quarter rather than a half, because water gathers where the ground funnels it and a cell along a channel is stepped on several times over what an average cell gets. ## Tiling A droplet takes at most `maxSteps` steps, and the caller keeps that at or below the apron width. Past that it would read cells whose real owner is a neighbouring tile and whose contents have not been exchanged since the batch began, and the two tiles would disagree about the ground at their seam. Droplets spawn in the interior only. A spawn in the apron would place a second droplet on ground the neighbouring tile has already rained on, doubling the rainfall in a band along every seam. Material a droplet carries out of the interior and into the apron is not lost: `terrain_delta_gather` adds it into the neighbour's interior before either tile folds its accumulator into height. What a droplet is still carrying when its walk ends goes with it. A run therefore lowers the ground it cuts: material leaves the uplands, and a field has no basin outside itself for it to arrive in. The fall in the field's mean is a small fraction of the depth the channels reach. ## Parameters Lane 1 carries `seed, batch, dropletCount, maxSteps`; lane 2 `solubility, deposition, evaporation, inertia`; lane 4 `minSlope, capacity, gravity, apron`. Heights are metres and horizontal distances are cells, so `capacity` and `minSlope` read as metres of fall per cell of run. Every op carries a mask, and for a simulation it bounds what a step MOVES. The mask scales both what a step cuts and what it lays down, and the material a step declines to move stays where it was — in the ground, or in the droplet — so a droplet carries its load ACROSS ground the mask holds out rather than losing it there. What a droplet still holds when its walk ends lands where the walk ended, whatever the mask reads there. The ground it walked is already lighter by exactly that much, so scaling that last drop would leave the difference nowhere and the pass would remove material instead of moving it. The mask therefore bounds where a run cuts exactly, and where it deposits everywhere except the cell a walk ends on. An op that names no mask erodes at full strength everywhere.
▲ 0↑ born
·
computeshader · born here
❒asset
# terrain_erode_thermal Talus-angle relaxation over one tile, one iteration per dispatch. Loose material resting on a slope steeper than its angle of repose slides down. Repeat that and cliffs grow scree fans at their feet while the crests a droplet pass leaves knife-edged round off — which is why this is usually the finishing pass over hydraulic erosion rather than a substitute for it. The rest angle is a world angle. A cell may stand `tan(talus) * cellSize` metres above a four-neighbour and still rest, so refining the grid resolves the same slope rather than changing which slopes are stable. Each iteration is one dispatch: a cell above the rest angle sheds a fraction of its excess, split across the neighbours below the angle in proportion to how far below each one is. Half the excess is the ceiling per iteration, above which the two cells either side of a step trade places every step instead of settling. Changes accumulate through the same fixed-point integer path droplet erosion uses, so the result is equally independent of the order threads land in. One thread per INTERIOR cell. The apron is read and never written: an apron cell belongs to the neighbouring tile, which relaxes it itself, and relaxing it here as well would move its material twice. Material that slides out of the interior lands in the apron, and `terrain_delta_gather` carries it into the neighbour before either tile folds its accumulator into height. Every op carries a mask, and for a relaxation the mask gates the cell that SHEDS. A cell the mask holds out keeps its own material, and what it still receives from an unmasked neighbour is the scree that slope throws past the mask's edge — material moving downhill, which is what this pass does. An op that names no mask relaxes at full rate everywhere.
▲ 0↑ born
·
computeshader · born here
❒asset
# terrain_fbm Domain-warped fractal noise written into one scalar channel of one tile. Gradient noise with quintic interpolation. Sample positions come from the field-wide cell index, the field's minimum corner, and the seed, so a tile evaluated on its own is identical to the same tile evaluated as part of the whole field — and a cell reached as one tile's apron lands on exactly the world position it has as its neighbour's interior. `featureSize` is the world size in metres of the largest noise feature. Halving `resolution` therefore refines the same landscape instead of producing a different one. `warp` is a world distance: how far the domain is displaced by two lower-octave fields before the main fractal is sampled. Non-zero warp is what turns isotropic lumps into ridgelines and basins. `ridge` folds the fractal about zero and squares it, giving sharp crests and rounded valleys. `blend` decides how the contribution meets what the channel already holds: `replace`, `add`, `max`, or `min`. Every op carries a mask. The op's weight at a cell is scaled by what the mask reads there — multiplied in, or subtracted out — so the op applies only where the mask lets it and a cell the mask holds out keeps exactly what it already held, at every blend mode. An op that names no mask writes at full strength everywhere.
▲ 0↑ born
·
computeshader · born here
❒asset
# terrain_index Writes one node's triangles into the terrain's shared index buffer. One thread per quad, six indices each. The node's vertices live at `vertexSlot` in the vertex pool and its triangles land at `writeSlot` in the index buffer. Keeping the two apart is what makes a cut change cheap: a node that survives a cut keeps its vertex slot, so a cut that only adds, drops or reorders nodes rewrites indices without re-meshing a vertex, and re-meshing a node for a moved camera rewrites vertices without touching an index. The active cut's indices are written as one contiguous run from index 0, so the whole terrain — every node, at every level — draws as a single indexed draw. Winding is CCW seen from +Y, the outward side of a heightfield.
▲ 0↑ born
·
computeshader · born here
❒asset
# terrain_layer Blends a co-registered raster layer into a scalar channel. A layer shares the field's grid exactly — same tile, same cell, same apron — so there is no sampling and no transform involved, only a blend. Compare `terrain_stamp`, which samples a texture through a world-space transform because the source has a grid of its own. That exactness is what lets freehand editing live inside a parametric stack. Brush strokes accumulate into a layer, the layer is one `layer` op among the others, and changing an op above it re-runs the stack with the strokes still in place. Painting is an edit to the layer's raster rather than a bake over the terrain the ops produced. `opacity` scales the layer before the blend, so the same raster can be dialled in and out without repainting it. `blendMode` follows the numbering every terrain op shares: 0 replace, 1 add, 2 max, 3 min. Every op carries a mask. The op's weight at a cell is scaled by what the mask reads there — multiplied in, or subtracted out — so the op applies only where the mask lets it and a cell the mask holds out keeps exactly what it already held, at every blend mode. An op that names no mask writes at full strength everywhere.
▲ 0↑ born
·
computeshader · born here
❒asset
# terrain_layer_weights Evaluates a material layer stack's rules into two RGBA weight textures — layers 0-3 in one, 4-7 in the other, with the eight shares at a texel summing to one. One thread per destination weight texel. The dispatch is per tile: a tile writes the texels whose centres fall inside its interior, reading the field through its own apron-inclusive buffer. Because every read is placed from the field minimum and the texel size in world metres — never from the tile's own origin — the texels one tile writes agree exactly with its neighbour's along the seam. ## The six inputs | input | unit | where it comes from | |---|---|---| | `height` | metres, world Y | the `height` channel, bilinear | | `slope` | degrees, 0 flat / 90 vertical | central difference of `height` over `gradientStep` metres | | `curvature` | 1/metres, + gully / − ridge | Laplacian of `height`, magnitude ≈ 1/radius | | `mask` | 0..1 | a bound `mask.<name>` channel, bilinear | | `noise` | 0..1 | `terrain_fbm`'s gradient noise over world position | | `channel` | the channel's own stored values | any bound field channel, bilinear | `slope` and `curvature` are derived here rather than stored, so they cost no channel and can never fall behind the ground they describe. `mask` and `channel` both read a named channel through the same four slots and the same bilinear tap; they differ in what the band is written against. A mask channel holds 0..1, so its band is in that range. A `channel` band is in the values the channel stores — cubic metres of deposited sediment, metres of water depth, whatever the op that wrote it produces — with no rescaling on the way in. That is what lets a layer put sand where the water actually dropped it and wet shore where water actually stands. ## A rule is a band, whatever it reads Every rule carries `min`, `max`, `softness` and `jitter` **in the unit of its own input**. A layer covers the ground by its `strength` times the product of its rules; a layer with no rules covers unconditionally. `invert` takes the complement of the band, which is how "anywhere but here" is written. `softness` is a feather half-width and `jitter` is how far noise is allowed to move the value before it is banded — metres on a height rule, degrees on a slope rule. Each rule draws its jitter from its own noise offset, so two rules on one layer do not wander in step. ## The layers are a stack Working from the top down, each layer takes its coverage share of whatever is still showing. A cliff rule that fully passes therefore leaves nothing of the grass under it, a layer at half coverage lets half the layer below through, and whatever no layer claimed goes to layer 0 — the ground the stack stands on. The eight shares add to one by construction, with nothing divided by a sum, and an unclaimed texel is a surface rather than a hole. Rules are numbered bottom-up across the whole stack and each layer reads its own range of them, so covering downward moves no rule's noise offset. ## Bindings `params` carries the whole stack: four head lanes, eight layer lanes, then three lanes per rule. `height` is the field's height tile. `chan0`..`chan3` are the field channels the stack's rules name, in sorted order — a slot no rule reads is bound to the height tile and never sampled. Four is what the kernel declares; a compute stage carries eight storage buffers and `params` and `height` take two, so six is the ceiling a stack could be given. `weightsLo` and `weightsHi` are `rgba8` storage targets, which is the widest format a material slot can sample: a material reads every slot through a filtering sampler, and the 32-bit float formats are not filterable.
▲ 0↑ born
·
computeshader · born here
❒asset
# terrain_mask A soft-band rule over one feature of a raster, written into a named layer. | input kind | value | unit | |---|---|---| | 0 height | the surface's own value | world metres | | 1 slope | the angle of its gradient | degrees from flat | | 2 curvature | its Laplacian — positive in a gully, negative on a ridge | 1/metre | | 3 noise | gradient noise over world position | 0..1 | | 4 curve | distance to a polyline's sampled control points | world metres | A band is `min .. max`, feathered by `feather` — a soft half-width in the input's own unit — on either edge; a side left open (`±1e29`) is not a bound at all. `jitter` perturbs the sampled value before the band, by up to `jitter` over a gradient-noise field sized `jitterSize`, so the boundary wanders instead of tracing a contour. `invert` swaps what the band selects. The `height`/`slope`/`curvature` inputs are derived from the raster's own "height" layer, read through `op_source` at the same resolution as the layer this op writes — one indexed read per cell, no resampling. `slope` and `curvature` are central differences over `step` world metres. The `curve` input reads a polyline's sampled control points off `op_payload` — the same array `terrain.project` rides — and measures distance to it in the ground plane; a control point's height plays no part in the distance. `blend` decides how the band's weight meets what the layer already holds: `replace`, `add`, `max`, or `min`. A layer written fresh is usually `replace`; combining two masks into one accumulated layer is `add` or `max`. The written value is a weight in 0..1 — a layer this op writes is what `terrain.mask`'s `name` param names, and any other raster kernel op's own `mask` param can reference it by that name, multiplied in or subtracted out, through the generic `op_mask`/`op_common` contract every raster kernel op already carries.
▲ 0↑ born
·
computeshader · born here
❒asset
# terrain_mesh Builds one quadtree node's vertices from a height channel tile, in the engine's 23-word vertex layout, straight into the shared vertex pool the terrain draws from. One thread per node vertex. Positions are world space. Normals and tangents come from a central difference over the field in the same pass, which is why the tile's apron has to hold real data — a normal at a node edge reads a cell that belongs to the neighbouring tile's interior. Two edge behaviours make an LOD cut renderable: - **Stitching.** `coarseMask` names the edges whose neighbour is one level coarser. On such an edge the odd-indexed vertices snap onto the previous even one, so the fine edge becomes the polyline through the coarse neighbour's own vertices. The crack closes because the two edges are then the same line, not because something covers it. - **Geomorphing.** Over the far end of the distance band a node is a leaf across, every vertex slides onto the position it would hold one level coarser. By the time the cut merges the node into its parent, the node is already drawing the parent's geometry, so the merge moves nothing. The morph runs off each vertex's own distance to the camera rather than the node's, so a vertex two nodes share is placed identically by both. A node coarser than one field tile covers several of them, so it is dispatched once per tile it overlaps and each dispatch writes only the vertices inside that tile's half-open world rect. Every position the stitch and the morph can produce stays inside the node, so the node's tiles cover its vertices exactly once.
▲ 0↑ born
·
computeshader · born here
❒asset
# terrain_project A curve drives the ground: the points of the curve a geometry holds ride `op_payload`, and every cell's height is banded by its ground-plane distance to the resulting polyline — one dispatch, so the channel it cuts has no seam where a chain of stamps would have met. Inside `width`, falling to zero across `falloff` metres beyond it, a cell blends toward a target height: - `useCurveHeight` set: the curve's own y at the nearest point on the polyline — a road or a riverbed that follows the curve's authored elevation. - otherwise: `fromHeight` .. `toHeight`, interpolated by how far along the polyline (ground-plane arc length, 0 at the first control point, 1 at the last) the nearest point falls — a ramp that runs the whole curve's length in one dispatch instead of one `ramp` stamp per segment. `blend` decides how the target height meets what the layer already holds: `replace`, `add`, `max`, or `min`. The polyline is open — segment `i` to `i+1` for every consecutive pair of the curve's sampled points — and distance and arc length are both measured in the ground plane; a point's height does not distort either.
▲ 0↑ born
·
computeshader · born here
❒asset
# terrain_scatter_density Evaluates one scatter rule into a density channel: **instances per square metre** at every cell of one tile. The unit is what makes a rule portable. An author asks for one tree per fifty square metres and gets that count whether the field is sampled at half a metre or at four, and whether the terrain is 512 m across or 4 km. ## The rule inputs are the material stack's inputs | Input | Where it comes from | |---|---| | height | the surface channel's own value, world metres | | slope | the angle of its gradient, radians | | curvature | its laplacian, 1/m — positive in gullies, negative on ridges and crests | | mask | a co-registered raster, multiplied in or subtracted out | | noise | gradient FBM over world position, so a forest edge is not a contour line | Each band contributes a weight in `0..1` and the weights multiply. A rule that sets one band and leaves the rest open is exactly that one band, so an author who understands one understands all of them. The density written is the peak density times the product of the weights. ## The region `atX/atZ`, `radius` and `falloff` bound where the rule applies at all, in world metres. A radius of **zero** covers the whole field — what a ground cover wants. Outside the region the shader writes nothing, so a second rule's density on that cell stands. ## The mask slot `maskMode` picks what the mask raster does: | mode | effect | |---|---| | 0 | multiply — the rule applies only where the mask is painted | | 1 | subtract — the rule is kept OUT of where the mask is painted | | 2 | none — the binding is not read | Subtract is how an authored `place` op's `clearing` keeps a scattered forest out of the ground a landmark stands on: the placement writes its footprint into the `placement` channel and the scatter rule subtracts it. A binding has to point somewhere, so at mode 2 the caller binds the height channel into the mask slot and the shader never reads it. One thread per cell, apron included. The cell's world position comes from the field-wide cell index and the field's minimum corner, so a cell reached as one tile's apron resolves to the same density as the same cell reached as its neighbour's interior. ## The op contract's mask This kernel carries no `op_mask` / `op_common`. Those bind the mask every op that writes GROUND is scaled by, and this one writes a density channel — it has the `mask` binding above, with modes and a gain of its own, which is the mask a scatter rule reads. The two are separate surfaces on purpose.
▲ 0↑ born
·
computeshader · born here
❒asset
# terrain_scatter_place Turns one tile's density channel into instance transforms in the shared pool `renderer.mesh.drawInstanced` reads. One thread per candidate **site**, not per cell. ## Sites are world-anchored The sites are a grid of `spacing` world metres running from the field's minimum corner. A site belongs to exactly one tile — the one its centre falls in — so the same field produces the same sites whatever the tiling, whatever the resolution, and however much of the field is being re-evaluated. A site carries an instance with probability `density * spacing^2`. That is what makes the density channel mean **instances per square metre**: summed over the sites of an area, the expected count is the integral of the density over it. One site carries at most one instance, so `1 / spacing^2` is the densest cover a spacing can express. `terrainScatter` derives the spacing from the density the rule actually reaches rather than letting a caller find that ceiling by hitting it. ## Determinism Acceptance, the jitter inside the site, the yaw and the scale are all hashes of the site's integer grid indices and the seed. The instance a site carries is therefore the same instance every run: a reload does not reshuffle a forest, and re-evaluating one corner of the field leaves the rest of the population exactly where it stood. Slots in the pool are handed out by an atomic, so **which** slot an instance lands in varies between runs. The set of instances and each one's matrix do not. ## Overflow is a resize, not a short forest When the accepted count runs past `capacity` the counter keeps counting and the extra instances are not written. The total it reports is what the caller grows the pool to before running the pass again, which is how a population that grew past its reservation ends up complete rather than silently clipped. ## The matrix layout Column-major 4x4, four `vec4` columns per instance, tightly packed — the layout a vertex shader reads as `array<mat4x4<f32>>`. Columns 0..2 are the basis scaled by the instance's scale; column 3 is the translation. ## The op contract's mask This kernel carries no `op_mask` / `op_common`. Those bind the mask every op that writes GROUND is scaled by, and this one writes instance transforms. Where a population may grow is already decided by the density channel it reads, and `terrain_scatter_density` is where a mask shapes that. The separation is on purpose.
▲ 0↑ born
·
computeshader · born here
❒asset
# terrain_shape Blends an analytic shape into one scalar channel of one tile. One shader covers every placed primitive, selected by a mode lane: | mode | shape | what it draws | |---|---|---| | 0 | disc | a circular pad held at one altitude | | 1 | square | the same, measured in the Chebyshev metric | | 2 | cone | a rounded hill, cosine profile, tangent to flat at the rim | | 3 | ridge | that profile swept along a segment | | 4 | ramp | an altitude interpolated along a segment | | 5 | terrace | the existing height quantized into steps | A negative height on a `ridge` cuts the same profile downward, which is how a valley or a sunken road corridor is drawn. The shape's coverage at a cell is a weight in 0..1 — full inside `radius`, falling to zero across `falloff` metres beyond it — and that weight is what the blend is applied through. A flatten therefore settles into the terrain rather than stamping a cliff-edged plateau into it. Every coordinate is world metres. A shape about a point puts that point in both segment endpoints; a shape along a line puts the two ends in them. `radius` is the half-width for the segment shapes. Because nothing here is in cells, the same op covers the same ground at any resolution. `blend` decides how the shape's value meets what the channel already holds: `replace`, `add`, `max`, or `min`. The rounded shapes read best with `add`, which lays a hill on top of the terrain instead of replacing it; the level shapes read best with `replace`. Every op carries a mask. The op's weight at a cell is scaled by what the mask reads there — multiplied in, or subtracted out — so the op applies only where the mask lets it and a cell the mask holds out keeps exactly what it already held, at every blend mode. An op that names no mask writes at full strength everywhere.
▲ 0↑ born
·
computeshader · born here
❒asset
# terrain_stamp Blends a source texture into a scalar channel under a world-space transform. The source is an ordinary `.texture`. That is the point: importing a real-world elevation model and placing an authored shape are the same operation, so there is one path for both and no separate importer. Sampling runs in world space. Each destination cell's world position is carried into the stamp's local square — translated to `at`, rotated by `rotation`, scaled so the square spans `radius * 2` metres — and the texture is read there. A cell outside that square is left untouched, so the op's declared extent is exact and the evaluator dispatches only the tiles the stamp reaches. `channelIndex` picks which of the texture's four channels carries the value; `amplitude` scales it into metres and `bias` offsets it, so an 8-bit greyscale heightmap in 0..1 becomes real elevation. `falloff` feathers the disc inscribed in the square, which is what lets a stamp meet the surrounding terrain without a seam. Compare `terrain_layer`, which blends a raster that already shares the field's grid and therefore needs no sampling at all. Every op carries a mask. The op's weight at a cell is scaled by what the mask reads there — multiplied in, or subtracted out — so the op applies only where the mask lets it and a cell the mask holds out keeps exactly what it already held, at every blend mode. An op that names no mask writes at full strength everywhere.
▲ 0↑ born
·
computeshader · born here
❒asset
# terrain_water Fills a water surface over a region of one tile, into the `water` and `waterMask` channels. One shader covers every water body, selected by a region-mode lane: | mode | region | what fills | |---|---|---| | 0 | disc | a round body about a point — a lake | | 1 | square | the same, measured in the Chebyshev metric | | 2 | path | a body swept along a segment, its surface running from one end's level to the other's — a river | | 3 | everywhere | the whole field — a sea | A cell takes the surface only where the ground stands **below** it, so a body fills the basins its region reaches and leaves ground that rises out of it dry. That is the whole difference between a lake and a sea: the region an op is applied to. ## The two channels are one thing | channel | what it holds | |---|---| | `water` | the world-space Y of the surface covering that cell | | `waterMask` | 1 where a surface covers the cell, 0 where none does | A mask of 0 is what "no water here" IS — there is no height in the `water` channel standing in for it, so no authored water level can ever be mistaken for an absence. Everything that reads water reads the pair. ## Bodies meet through `blend` Where two bodies reach one cell, the second meets the first through `blend` — `replace`, `add`, `max` or `min`. The default `max` makes the meeting independent of authoring order: the higher surface wins whichever op ran first, so a lake in a coastal basin reads the same whether it was placed before or after the sea. Every coordinate and every level is world metres, so a body keeps its size, its place and its altitude when the field's resolution changes. Every op carries a mask. The op's weight at a cell is scaled by what the mask reads there, and here the water mask channel is what carries that coverage: a cell the mask holds out keeps both channels as they stand, and a partly covered cell holds part of the surface it already had. An op that names no mask fills at full strength everywhere.
▲ 0↑ born
·
computeshader · born here
❒asset
# terrain_water_kernel Fills a water surface over a region of one tile, into the `water` and `waterMask` layers of a `proc.raster` `Raster` — `terrainOps.water`'s `lake`, `river` and `sea` share this one dispatch. One shader covers every water body, selected by a region-mode lane: | mode | region | what fills | |---|---|---| | 0 | disc | a round body about a point — a lake | | 1 | square | the same, measured in the Chebyshev metric | | 2 | path | a body swept along a segment, its surface running from one end's level to the other's — a river | | 3 | everywhere | the whole raster — a sea | A cell takes the surface only where the ground stands **below** it, so a body fills the basins its region reaches and leaves ground that rises out of it dry. That is the whole difference between a lake and a sea: the region an op is applied to. The ground it reads is `op_source`, bound to the raster's `height` layer. ## The two layers are one thing | layer | what it holds | |---|---| | `water` | the world-space Y of the surface covering that cell | | `waterMask` | 1 where a surface covers the cell, 0 where none does | A mask of 0 is what "no water here" IS — there is no height in the `water` layer standing in for it, so no authored water level can ever be mistaken for an absence. Everything that reads water reads the pair. ## Bodies meet through `blend` Where two bodies reach one cell, the second meets the first through `blend` — `replace`, `add`, `max` or `min`. The default `max` makes the meeting independent of authoring order: the higher surface wins whichever op ran first, so a lake in a coastal basin reads the same whether it was placed before or after the sea. Every coordinate and every level is world metres, so a body keeps its size, its place and its altitude when the raster's resolution changes. Every op carries a mask. The op's weight at a cell is scaled by what the mask reads there, and here the water mask layer is what carries that coverage: a cell the mask holds out keeps both layers as they stand, and a partly covered cell holds part of the surface it already had. An op that names no mask fills at full strength everywhere.
▲ 0↑ born
·
computeshader · born here
❒asset
# terrain_water_mesh Builds one node of the water surface from the `water` / `waterMask` channels, into the same 23-word vertex layout and the same slot-addressed pool `terrain_mesh` writes. ## The baked column Alongside altitude, each vertex bakes the WATER COLUMN — how far the surface stands above the bed — into the vertex colour's red channel (word 19): `max(water - height, 0)` where covered, `0` where not, and a sentinel standing for open ocean on a flat skirt vertex, which has no channel to measure against. `terrainWater.shader` reads it back as an interpolated attribute instead of reconstructing it from the scene depth buffer, which a plain material draws with no depth copy behind it. ## Where a vertex's altitude comes from | the cell under the vertex | y | |---|---| | covered by water | `water` — the surface itself | | not covered | `min(water, height - sink)` | The uncovered branch is what ends the surface at the shore. `height - sink` is under the terrain by construction, so a vertex with no water above it is buried whatever the water channel holds there; taking the lesser of the two keeps the plane flat right up to the last covered vertex, so the waterline falls where the plane and the ground cross rather than on a node boundary. Coverage is read from the nearest cell rather than interpolated: a cell either carries a surface or it does not, and half-covered is not a state the surface has. ## The skirt A node with `flat` set reads no channel and sits at `flatLevel`. That is the outer skirt — the ring of nodes carrying the sea from the field's rect out to the horizon, so an island has water all the way around it instead of stopping at the grid. Skirt vertices meet the rect's edge vertices at exactly the sea level, which is what makes the join seamless rather than merely close. Because the skirt's edge pieces are as long as the field and as deep as the horizon is far, a node here is a **rectangle**: `nodeSizeX` and `nodeSizeZ` are separate. ## Stitching Boundary stitching is the same rule as `terrain_mesh`: on an edge whose neighbour is one level coarser, the odd boundary vertices snap onto the previous even one. There is no geomorph — a level surface has nothing to pop.
▲ 0↑ born
▦
texture · born here
❒asset
▲ 0↑ born
▦
texture · born here
❒asset
▲ 0↑ born
▦
texture · born here
❒asset
▲ 0↑ born
▦
texture · born here
❒asset
▲ 0↑ born
▦
texture · born here
❒asset
▲ 0↑ born
▦
texture · born here
❒asset
▲ 0↑ born
▦
texture · born here
❒asset
▲ 0↑ born
▦
texture · born here
❒asset
▲ 0↑ born
▦
texture · born here
❒asset
▲ 0↑ born
▦
texture · born here
❒asset
▲ 0↑ born
·
guide · born here
❒asset
# terrain guide The terrain system's reference guide: a landscape composed in code as a procgen graph — the `Raster` the `terrain.*` ops pass along, the source → shape → mask → realize shape of a terrain graph, every op in the family, and a worked valley with a river. Surfaced through `guides { query: "terrain" }` and `guides { path: "systems/terrainSystem/documentation/terrain" }` (likewise `man`). The content lives in `guide.md`; this README is the asset summary. The hand-authoring face of the same system is the `terrain` toolbox (`search_tools terrain`), which carries its own README.
▲ 0↑ born
◐
material · born here
❒asset
# terrainSplat (material) The eight-layer terrain surface. Blends three layer atlases — albedo, normal, mask — by the two RGBA weight textures the landscape was baked with. ## You normally do not apply this material yourself `terrainSurface.forLandscape(data)` instances it per landscape: the `.terrainData`'s control textures bound as the weight maps, its rect as `field_min` / `field_size`, its packed sheets as the three atlases, and its layer table as the per-layer tints and tilings. What you apply is the handle that call returns — and a `Terrain` component does even that for you. This asset is the same shader with defaults, so it is inspectable and it is what a hand-authored variant starts from. Applied by hand it has no weight textures and no field rect, so it renders **layer 0 everywhere** — an unbound stack looks unbound rather than looking broken. ## Properties | Property | Unit | What it does | |---|---|---| | `field_min`, `field_size` | metres | the rect the weight textures cover; zero size means no weight map | | `weights_lo_data`, `weights_hi_data` | — | layers 0-3 and 4-7, normalised to sum to one | | `layer_atlas_albedo` / `_normal` / `_mask` | — | 4x2 atlases, layer N at (N mod 4, N div 4) | | `atlas_size` | texels | atlas dimensions, for the half-texel inset between cells | | `layerN_tint` | — | multiplies layer N's albedo sample | | `layerN_size` | metres | world metres one repeat of layer N's texture covers | | `weight_sharpen` | — | 1 is the stack as evaluated; higher narrows every transition without moving it | | `height_blend` | — | 0 blends by weight alone; higher gives a contested pixel to whichever layer's mask height stands proudest | | `normal_strength` | — | scales the blended tangent-space XY; 0 is flat | | `smoothness`, `metallic` | — | scale the mask atlas's alpha and red | ## The mask atlas Each cell is a mask MAP in the usual channel order: **R metallic, G ambient occlusion, B height, A smoothness**. White is the neutral one — no occlusion, a level height that leaves `height_blend` with nothing to reorder, and metallic and smoothness left to the two scalars. The height channel is what makes a transition look like two materials meeting rather than a crossfade: where the weights call a tie, `height_blend` gives the pixel to the layer standing proudest there, so sand settles into the gaps between cobbles instead of fading evenly across their faces. Layers at equal height are attenuated equally, so a white atlas comes through unchanged. ## Three atlases, not twenty-four slots A slot per layer per map would put twenty-four sampled textures in one bind group, which is past what WebGPU guarantees a shader stage. The atlas keeps the cost at three whatever the layer count is. `terrain.write` packs the sheets when it bakes a landscape — a layer names its own `albedo` / `normal` / `mask` textures and they are resampled into that layer's cell — so the packing is not something an author does by hand. A stack with no atlas at all renders its eight `layerN_tint` colours, which is enough to see every rule working.
▲ 0↑ born
◐
material · born here
❒asset
# terrainStandard (material) Built-in default material for the terrain system. Uses the `~terrainSystem.shaders.terrainBlend` shader, which renders a four-layer surface blended by altitude and slope, with a procedurally-detailed cliff override for steep faces. ## Layers | Slot | Source field | Default | Role | |------|--------------|---------|------| | 0 (valley) | `colors.base_color` | green | Low ground, grass, valley floor. | | 1 (mid) | `colors.layer1_color` | tan | Mid-band: dry grass / scrub between grass and rock. | | 2 (high) | `colors.attenuation_color` | grey | High band: rock, exposed cliff face. | | 3 (peak) | `reflectance` (engine default ≈ white) | white | Peaks: snow, ice. | ## Tunables (re-purposed StandardMaterial slots) | Material field | Shader role | |----------------|-------------| | `parallax_depth_scale` | `layer1_height` — Y where layer 1 mid-band peaks. | | `max_parallax_layer_count` | `layer2_height` — Y where layer 2 rock takes over. | | `lightmap_exposure` | `layer3_height` — Y where layer 3 snow takes over. | | `normal_scale` | `blend_width` — width of each altitude transition. | | `clearcoat` | `slope_start` — cos-θ where cliff blend begins. | | `clearcoat_roughness` | `slope_end` — cos-θ where cliff fully overrides. | | `anisotropy_strength` | `detail_scale` — boundary-breakup noise frequency. | | `anisotropy_rotation` | `(detail_amp, macro_scale)` — boundary amp + macro tiling. | Authors who want different colours / heights just override the respective `floats.` / `colors.` blocks in their own material. ## Customizing per terrain ```lua Material.Create("alpine_meadow", "~terrainSystem.shaders.terrainBlend", { base_color = { 0.18, 0.45, 0.22, 1 }, -- meadow green emissive = { 0.35, 0.42, 0.30, 1 }, -- scrub attenuation_color = { 0.55, 0.55, 0.55, 1 }, -- granite parallax_depth_scale = 12, -- layer1 (scrub) peak max_parallax_layer_count = 24,-- layer2 (rock) onset lightmap_exposure = 38, -- snow onset clearcoat = 0.7, clearcoat_roughness = 0.3, }) ``` ## Caveats - This iteration paints **solid colours per layer**. Texture sampling per layer is a planned extension — swap the `mix(albedo, layerN_color, wN)` in the shader for a tiled `textureSample(layerN_texture, ...)` and add the texture slots to the material schema. Tracking issue: filed alongside the terrain system PR. - Author-controlled splatmap (an RGBA texture that paints which layer is at each pixel) is similarly planned. The current altitude/slope blend is procedurally driven only.
▲ 0↑ born
◐
material · born here
❒asset
# terrainWater The material a terrain field's water surface draws with — lakes, rivers, the sea, and the skirt that carries the sea past the field's rect. It is a plain transparent PBR body of water with no simulation behind it, which is what makes it correct at every scale the surface is used at: a pond, a river reach and a horizon all read the same. `terrainSystem.spawn` applies it to a terrain's water entity unless `waterMaterial` names another. It draws in the transparent queue after the terrain, with both faces, so the ground under the surface is already in the frame and the same material is the underside once the camera goes below the waterline. Reach for `@builtin::systems.oceans` instead when the water is the subject — that package simulates a spectrum, whitecaps and shore foam, and wants an ocean rather than a terrain's water table.
▲ 0↑ born
▣
module · born here
❒asset
# terrainHeightfield A `.texture` standing over a rect of world, read as ground. The durable form of a landscape is an ordinary texture asset — one with a guid, that a component references, that travels with a world, that the renderer already knows how to sample. `terrain.to_texture` is what puts a graph's raster into one and `texture.write` persists it. This module is the other end: what a runtime holds while that ground is on screen. ```lua local terrainHeightfield = require("~terrainSystem.modules.terrainHeightfield") -- A `.terrainData` states the rect it covers, so opening one needs nothing else. local hf = terrainHeightfield.open("/zero/source/terrain/dunes.terrainData") -- The heightfield alone can be read too; then the caller says where it stands. local bare = terrainHeightfield.open(tex, { size = { 4000, 4000 }, origin = { 0, 0 } }) local y = hf:heightAt(120, -60) -- metres local nx, ny, nz = hf:normalAt(120, -60) local slope = hf:slopeAt(120, -60) -- radians hf:close() ``` ## The texels `rgba32f`, in the layout `terrain.to_texture` writes: | Channel | Holds | |---|---| | R | the height, in METRES — it goes negative | | G | the gradient along world X | | B | the gradient along world Z | | A | coverage | A height's interesting relief is often a few metres inside a range of hundreds, so eight bits band it into terraces; that is what the precision is for. The gradient is what a surface normal comes out of without differencing the height four more times, and it is why `terrain_mesh` costs four texel reads a vertex rather than twenty. A heightmap authored anywhere else — an 8-bit image, a downloaded elevation tile, a raster an earlier graph baked — becomes one of these by going through a graph: `terrain.from_texture` reads it as ground, and `terrain.to_texture` writes the result back out at this precision. ## Two carriers, one set of texels An open heightfield holds the texture in both forms the runtime reads it in: - **`hf.texels`** — a GPU buffer of the texel bytes, which the `terrain_mesh` compute kernel builds vertices out of. - **`hf.cpu`** — the pixels, which `heightAt` / `normalAt` / `slopeAt` / `sampleGrid` answer from. Both are the same texels, uploaded from one read, so what the ground LOOKS like and what a query SAYS about it cannot drift apart. ## The surface | Verb | | |---|---| | `open(ground, opts?)` | Read a `.terrainData` — or the heightfield `.texture` alone — as ground. A container carries its own `{ size, origin, heightRange }`; a bare texture takes them from `opts`, and an explicit opt outranks the container either way. | | `hf:heightAt(x, z)` | Bilinear metres. | | `hf:normalAt(x, z)` | Unit normal, three numbers, from the carried gradient. | | `hf:slopeAt(x, z)` | Radians; 0 is flat. | | `hf:sampleGrid(cols, rows)` | Row-major heights over the whole rect — the shape a heightfield collider takes. | | `hf:hasGroundAt(x, z)` | Whether there is ground at all — false over a hole. | | `hf:refresh()` | Read the texture's pixels again and put them back on the GPU. Bumps `gen`. | | `hf:close()` | Let go of the pixels and the GPU buffer. The asset is untouched. | `heightRange` is the vertical reach the geometry declares, which becomes the mesh's bounding box — a range smaller than the ground culls the terrain away at grazing angles. Left out, it is read off the pixels, which costs one pass over them. ## Holes A landscape can be CUT OUT — a cave mouth, a quarry, a shaft — and its holes mask is carried the same two ways the heightfield is: on the GPU for the index kernel, which writes the quads it cuts out as degenerate triangles, and on the CPU for `hasGroundAt`. Same pixels, so a place with no surface to draw is a place nothing can be stood on either, and scatter that asks before it places never drops a tree into a shaft. A landscape with no mask is solid everywhere and answers `hasGroundAt` true without reading anything. The edge of a hole follows the mask at the density the node is meshed at, so a hole is quad-shaped at the LOD it is seen from. ## Generations `hf.gen` counts the times the texels have been read. `terrainMesh` records the generation each node was meshed at, so re-authoring the ground under a standing terrain and calling `hf:refresh()` brings the geometry with it — nobody has to say which nodes changed.
▲ 0↑ born
▣
module · born here
❒asset
# terrainMesh Puts a heightfield on screen as **one draw**. ## What it is A vertex **pool** of `maxNodes` slots, each a fixed `nodeVerts` square grid (33x33 by default). A quadtree **cut** decides which parts of the ground are drawn at which density: near nodes are small and dense, far nodes large and sparse, and every node meshes to the same grid, so a node costs the same wherever it is and the cut stays a few hundred nodes at any camera distance. `terrain_mesh` fills the slots the cut needs; `terrain_index` writes the index run over them; `renderer.mesh.create { vertexBuffer, indexBuffer }` turns the pair into one mesh. One entity carries one `Model` bound to it, so the whole terrain — every node, at every level — is one indexed draw. **Nodes are never entities.** Entity count is the binding constraint on this renderer; a per-chunk entity would put a hundred draws where this puts one. A step that seems to need one is a step that has the design backwards. A cut change calls `renderer.mesh.update` under the **same guid**, so the Model is never re-bound and the scene graph is never touched. ## Using it ```luau local terrainHeightfield = require("~terrainSystem.modules.terrainHeightfield") local terrainMesh = require("~terrainSystem.modules.terrainMesh") local hf = terrainHeightfield.open(groundTexture, { size = { 2048, 2048 } }) local mesh = terrainMesh.create(hf, { camera = terrainMesh.cameraPosition() }) terrainMesh.updateCut(mesh, hf, { x = 900, y = 30, z = 900 }) ``` The `Terrain` component does all of this from a `.texture` on an entity and follows the on-screen camera with the cut every frame, in edit and in play alike. Reach for the module directly only when the ground is not an entity's. ## The vertices `terrain_mesh` writes the engine's 23-word vertex layout, one thread per node vertex, positions in WORLD space — so the entity carrying the mesh stands at the world origin with no transform of its own. It reads the heightfield's texels as one array over the whole rect. The height is bilinear across the four texels around a position, and the normal comes from the gradient those same four texels already carry — four reads a vertex rather than the twenty a central difference of the height would cost. ## Holes `terrain_index` writes one node's triangles, and a quad whose ground the landscape cuts out is written DEGENERATE — six copies of one index — rather than left out. The run is a fixed stride per node, so a quad that vanished would shift every quad after it; a triangle whose three corners are one point covers no pixels and is discarded before it rasterises. A hole therefore costs the index slots it already owned and nothing else. The quad's own centre decides, so the edge of a hole follows the mask at the density the node is meshed at. The run carries the heightfield's generation as well as the slot order, so a mask re-read under a standing terrain moves the hole with it. ## Cracks and popping Two things happen at node edges, and both are in `terrain_mesh`: - **Stitching.** The cut is *balanced* — no leaf borders a neighbour more than one level coarser — and each node carries the mask of which of its four edges do. On such an edge the fine node's odd-indexed vertices snap onto the previous even one, so the fine edge becomes the polyline through the coarse neighbour's own vertices. The two edges are then the same line. - **Geomorphing.** Over the far end of the distance band a node is a leaf across, its vertices slide onto the positions they would hold one level coarser. By the time the cut merges the node into its parent, the node is already drawing the parent's geometry. The morph runs off each vertex's own distance to the camera, so a vertex two nodes share is placed identically by both. ## What re-meshes, and when A node is re-meshed when it is new to the cut, when its coarse-neighbour mask changes, when the camera moves (the geomorph is camera-relative), or when the heightfield's `gen` has moved past the generation that node was last built at. Nothing else touches it — `updateCut` from a viewpoint the cut was already taken from remeshes zero nodes and reports `changed = false`. `terrainMesh.invalidate(state, rect)` marks the nodes overlapping a world rect, for when something changed the ground in one place. ## Nodes that are not drawn `create` takes a `keep` predicate — `(node) -> boolean` — and the cut then covers whatever it keeps rather than the whole rect. That is what lets the terrain END before the grid does: an island's seabed is ground nobody can see, and ground nobody can see is ground nothing has to mesh. The predicate is a parameter of selection rather than a filter applied afterwards, because the node budget is spent on what is **drawn**: a cut that coarsened to fit nodes it then threw away would be spending the budget on seabed. It runs after the tree is balanced, so what it drops cannot unbalance what remains, and a node whose coarser neighbour was dropped has nothing on that side to crack against. ## Ground that is not square The quadtree tiles the rect with square **root** nodes. A square rect is one root; an oblong one is a row or grid of them, sized by `rootSize`, which defaults to the shorter side. A rect whose sides are not a whole number of `rootSize` squares is refused by name — pass a `rootSize` that divides both. ## The bounding box The mesh's box is the heightfield's rect by its declared `heightRange`. It is taken from the declaration rather than reduced from whatever is drawn, because a box that is too small culls the terrain away at grazing camera angles and the symptom is nothing at all on screen. ## Entity binding `terrainMesh.bind(entityId, hf, state)` records that an entity's geometry IS this heightfield drawn through this pool. `forEntity` reads it back, `isBound` asks, and `unbind` releases the mesh, the pool and the heightfield's pixels together. `followCamera(entityId)` is the per-frame entry the `Terrain` component calls.
▲ 0↑ born
▣
module · born here
❒asset
# terrainOps The `terrain.*` procgen op family: a whole landscape (a valley, a mountain range, an island ending in a coastline, a plateau cut into terraces) built as a procgen graph. `M.ops` is the family, keyed by registry id. `procPacks/terrain.procPack` returns it, and that asset's registration is what puts every op in the proc registry, the same `.procPack` path any author outside this package uses to contribute a family. The procgen toolbox's `searchNodes` lists them and `describeNode` gives one's inputs, params and outputs. The nodes a graph calls as `g.terrain.<name>` are this package's `terrain.nodebox`. | Movement | Ops | |---|---| | source | `terrain.source`: the rect of ground, in metres, at a resolution | | shape | `terrain.fbm` · `terrain.cone` · `terrain.ridge` · `terrain.ramp` · `terrain.flatten` · `terrain.terrace` · `terrain.stamp` · `terrain.layer` · `terrain.project` · `terrain.erode` · `terrain.coast` · `terrain.sea` · `terrain.lake` · `terrain.river` | | mask | `terrain.mask` writes a named layer; every raster kernel op reads one through `mask` / `maskMode` / `maskGain` | | bake | `terrain.write`: the whole landscape as a `.terrainData`: heightfield, control textures, holes mask, layer table | | carry | `terrain.to_texture` · `terrain.from_texture`: one layer in and out of a `.texture` | | stand | `terrain.scatter` · `terrain.drape`: points onto the surface, and anyone else's points back down onto it | The value every op takes and returns is a `proc.raster` **`Raster`**: a tiled GPU grid carrying named scalar layers (`height`, `water`, `waterMask`, and whatever `terrain.mask` names). ## What is in here | File | Role | |---|---| | `init.luau` | Assembles the family into `M.ops`. Owns `terrain.source`. | | `shape.luau` | The placed shapes: `flatten`, `cone`, `ridge`, `ramp`, `terrace`. | | `noise.luau` | `terrain.fbm`: multi-octave, optionally domain-warped fractal relief. | | `mask.luau` | `terrain.mask`: a soft band over one feature, written to a named layer. | | `project.luau` | `terrain.project`: a curve driving the ground by distance to its polyline. | | `surface.luau` | `layer`, `stamp`, `coast`: an external source blended into height. | | `water.luau` | `lake`, `sea`, `river`: one water body over three regions. | | `erosion.luau` | `terrain.erode`: droplet and thermal erosion batched over the tiles. | | `convert.luau` | `to_texture`, `from_texture`: a raster layer and a texture are the same field in two carriers. | | `write.luau` | `terrain.write`: the whole landscape as one `.terrainData` container. | | `scatter.luau` | `terrain.scatter`, `terrain.drape`: a point cloud off the ground, and points put back on it. | | `scene.luau` | `terrain.ground`: the ground the scene's Terrain entities stand on, which `drape` sets points on. | | `curve.luau` | A `Curve`'s control points flattened for the payload binding, shared by `project` and `mask`. | | `lanes.luau` | The eight vec4 dispatch lanes a placed-shape kernel's numbers are packed into. | | `modes.luau` | The shader numberings the ops declare. | `M.packers` and `M.payloads` expose each kind's dispatch numbers with no GPU dispatch and no readback, which is what a test verifies the maths through. ## Out of the graph A landscape leaves as a `.terrainData`: the heightfield, the control textures saying which surface covers where, the holes mask, the water surface and the table of what those surfaces are, held together as one asset. `terrain.write` takes the raster, a `name`, and the `layers` (the mask layer names holding each surface's coverage, in channel order), and answers the reference to the container. Its children are ordinary `.texture` assets written inside the container, so nothing here is a private format and a material samples a control texture the way it samples any texture. A `Terrain` component standing an entity on that container is what puts the ground in a scene. `terrain.to_texture` carries ONE layer out on its own, for a raster that is feeding something other than a landscape; `terrain.from_texture` is the way back in, and it is how a heightmap made anywhere else becomes ground these ops can carve.
▲ 0↑ born
▣
module · born here
❒asset
▲ 0↑ born
▣
module · born here
❒asset
▲ 0↑ born
·
nodebox · born here
❒asset
# terrain Ground as nodes. `terrain.source` opens a rect of ground as a tiled raster with a `height` layer; every other node takes that raster, changes it and hands it on. | Part | Nodes | |---|---| | the ground | `source`, `from_texture`; a landscape asset comes in through `io.import` | | landforms | `flatten`, `cone`, `ridge`, `ramp`, `terrace`, `coast`, `layer`, `stamp`, `project`, `fbm` | | rules | `mask` writes a soft band over height, slope, curvature, noise or a curve into a named layer; any node's `mask` input names it | | water | `lake` (floods the seed's basin until it is dry), `sea`, `river` | | weather | `erode`: droplets walked on the device with integer sums, or thermal relaxation | | standing on it | `scatter`, `drape` | | out of the graph | `landscape` (one element standing as the ground, the `Terrain` component on it), `write` (the same landscape saved as a named `.terrainData`), `to_texture` | The landforms are one pattern: a coverage weight (`footprint`), a profile of the distance (`dome`) or a value, and `core.raster.blend`. Parts the nodes share: `footprint`, `dome`, `mask_weight`, `nearest` (the nearest point of a curve), `surface` (height, normal and slope under a position), `bilinear` (a grid of samples read between the four around a point), `texel` and `image` (a layer read per texel of an image, and the image's texels), `dimensions`, `wet` (water into a region), `control` (normalized surface weights packed as 8-bit words). The raster holds more ground than its rect: the tiles store the cells of a border tile past the rect and a ring of cells around the tiles, and every landform and every blend writes them at their own world positions, as the tiles of the ops did. A node that reads at an offset (`cell` with `dx`, `dz`, the slope and curvature of `mask`, thermal `erode`, the walk of a droplet) reads those cells, so ground beside the edge slumps, drains and washes into them. The water floods (`lake` fill) stay inside the rect. A call answers a node's output directly when it has one (`g.terrain.cone({ ... })` is the raster) and a record when it has several (`g.terrain.footprint({ ... }).weight`). `nearest` holds the module that reads a curve's polyline. `landscape` and `write` name the landscape record kind (`records/terrain.procRecord` of this package), whose module declares the surfaces and writes the container. The noise terrain's relief and masks are made of is the noise prelude of `kernels/` (`zero_angle_fbm2` and `zero_angle_warped2` for `fbm`, `zero_proc_gradient` for the noise mask), called through `field.call`. Erosion droplets are a step Field handed to the `walk` kernel, their changes summed with the `scatter` kernel.
▲ 0↑ born
·
procnode · born here
❒asset
# Landscape (procNode) The last node of a terrain graph: the ground as one element. It is `terrain.landscape` under the name a graph ends a landscape with: the raster goes in, and one point comes out carrying the `Terrain` component, its `terrain` a landscape record holding the heightfield, the control textures, the holes mask, the water surface and the table of what the surfaces are. `io.output` stands the element as an entity with the ground, its water and its colliders, and `io.export` saves it with its `.terrainData` inside the asset it writes. A terrain among other elements is merged and parented like any element. ## Inputs - `raster` (Raster): the ground. - `name` (String, default `"landscape"`): the entity's name, the element's `name`. - `height` (String, default `"height"`): the raster layer written as the heightfield. - `water` (String, default `"water"`) and `waterMask` (String, default `"waterMask"`): the raster layers holding the water surface and where it covers. - `layers` (String, default empty): the surfaces, in channel order, as the names of the raster layers holding their coverage, separated by commas: `"sand, grass"`. - `surfaces` (List of records, optional): what each surface in `layers` is shaded with, `{ name, tint?, albedo?, normal?, mask?, tiling? }`, matched to a layer by `name`. - `holes` and `solid` (String, default empty): a mask layer marking where ground is cut out, or where it exists. - `resolution` (Int, default 0): texels across the longer axis; 0 takes one texel per raster cell. - `layerResolution` (Int, default 0): the edge of one layer slice; 0 takes the largest edge any layer map carries. - `fields` (Record, optional): fields of the `Terrain` component beside the ground: `material`, `waterMaterial`, `collision`, `collisionResolution`, `size`, `origin`, `heightRange`, `water`. ## Output - `terrain`: one element carrying `Terrain`, standing as a landscape.
▲ 0↑ born
·
procpack · born here
❒asset
# terrain Ground, as procedural operations. `terrain.source` opens a rect of ground as a tiled `Raster` with a `height` layer; every other op in the family takes that `Raster`, changes it, and hands it on — so a valley, a mountain range, an island and a coastline are all the same graph with different nodes in it. `terrain.write` carries the result back out — into a `.terrainData` that a `Terrain` component stands an entity on. Every op is an ordinary procgen op, so any graph can reach them and they compose with the rest of the registry: the shaping ops take a `Raster` and hand one on, and the ops that stand things on the ground hand back points. ## Ops **The ground** - `terrain.source` — the rect a terrain graph starts from: a tiled raster with a height layer, at a chosen resolution. **Shapes and noise** - `terrain.fbm` — fractal noise over the height layer; ridged or plain. - `terrain.flatten` — level the ground inside a placed shape. - `terrain.cone` — a cone or crater at a placed centre. - `terrain.ridge` — a raised or cut ridge along a placed line. - `terrain.ramp` — a graded slope between two heights. - `terrain.terrace` — quantize height into steps. **Rules and blending** - `terrain.mask` — write a soft band over one feature (height, slope, or distance to a curve) into a named layer. - `terrain.project` — band height by distance to a curve. - `terrain.layer` — blend a painted layer into height. - `terrain.stamp` — blend a texture into height at a placement. - `terrain.coast` — pull the ground outside a landmass down to the shore. **Water** - `terrain.lake` — fill a basin to a water level. - `terrain.river` — carve and fill a channel along a curve. - `terrain.sea` — flood everything below a level. **Erosion** - `terrain.erode` — hydraulic (droplet) or thermal erosion, batched over the raster's tiles. **Standing things on it** - `terrain.scatter` — points across the surface, weighted by a mask layer and banded by slope and height, each carrying the normal and slope under it. - `terrain.drape` — anyone else's points put down onto the ground: a graph's own `raster`, or the scene's `ground`. - `terrain.ground` — the ground the scene's Terrain entities stand on, as a graph value read afresh every frame. Wired into `terrain.drape`, points land on it wherever the graph stands, each marked `grounded`, and a Generator sets them again when the ground is rebuilt or the Generator moves. **Out of the graph** - `terrain.write` — the whole landscape as a `.terrainData`: the heightfield (`rgba32f`, R metres, G/B gradient), the control textures saying which surface covers where (`rgba8`, four layers a texture, weights normalized to sum to one), the holes mask, and the table of what those surfaces are. - `terrain.to_texture` — ONE layer as a Texture on its own, for a raster feeding something other than a landscape. - `terrain.from_texture` — the way back in, and how ground made anywhere else becomes something these ops can carve. ```luau g:node("bake", "terrain.write"):input("raster", ground) :param("name", "dunes"):param("layers", { "sand", "rock" }) ``` Then a `Terrain` component standing on that landscape is what puts it in a scene.
▲ 0↑ born
·
procrecord · born here
❒asset
# landscape (procRecord) The landscape record kind: the ground a raster becomes, with every texture it needs read back from the device (the heightfield, the holes mask, the water surface and its shade, one control texture per four surface layers), the surfaces it declares, and the rect it covers. `terrain.landscape` answers one, held on one element as `Terrain = { terrain = <record>, ... }`. Live, a record becomes a `.terrainData` written in the session's scratch folder, the asset the `Terrain` component's `terrain` field stands on; it is removed once nothing stands on it. Saved, it is a `.terrainData` container with its textures as `.texture` children inside it, written inside the folder of the asset being exported. `terrain.write` saves one under the name and folder it is given. `landscape.module` is the writer both use: `declare` states the surfaces a layer list names, and `writeLandscape` writes the container.
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
·
assettype · born here
❒asset
# Package (asset type) A package is an asset — a `<Name>.package/` folder that bundles components, modules, scenes, presets, tools, and any other typed sub-assets under one identity, installed into a world as one unit. Packages are how authoring sets get distributed across worlds. A **library** is the addressable root scope content resolves under (`@builtin`, `@<name>`) — a whole world mounted into this one under a namespace, via `zeromind.installLib` / `world.installLibrary`. A package is an asset that lives inside such a root, and one library holds as many packages as its author put there. A package is installed by guid the way every asset is (`zeromind.install`, `zeromind.installAsset`). ## When to use one - You're authoring a coherent feature suite (character controller stack, voxel engine, UI kit, animation library) and want to ship it as one install target. - You want a stable identity worlds can install and resolve sub-assets against (`@<lib>::pkg.sub_module`). - You want versioning + a per-package metadata layer that travels with the contents. If you're authoring inside a single world and don't need a separate install lifecycle, you don't need a package — drop your `.component` / `.module` / etc. directly in `/zero/source/`. Packages are for cross-world distribution. ## Where it lives - Source: `/zero/source/.../<Name>.package/` (or, for legacy packages, a folder containing `package.yaml` at its root). - Identity: `<Name>` (the `.package` suffix collapses in identity — mirrors `.module`). A file at `<Name>.package/<sub>/init.luau` resolves under the same identity it would at `<Name>/<sub>/init.luau`. - Folder shape (container-style — open child set): - `package.yaml` — metadata (name, version, description). **Required.** - `README.md` — instance documentation. **Required.** - `.metadata` — agent-editable tags + free-form fields. **Required.** - Sub-asset folders: any combination of `.component/`, `.module/`, `.scene/`, `.preset/`, `.tool/`, `.toolbox/`, `.material/`, `.shader/`, etc. The validator permits unlisted children (`allow_unlisted: true`) by design — packages are open containers. ## How to create one ```luau asset.create("package", "<Name>") -- Creates: /zero/source/<Name>.package/ -- package.yaml (metadata scaffold) -- README.md (instance README template) -- `folder` places it in a subfolder of /source instead of the root: asset.create("package", "<Name>", { folder = "systems" }) ``` Then scaffold sub-assets inside it: ```luau local pkg = asset.resolve("<Name>.package") asset.create("component", "Spinner", { into = pkg }) asset.create("module", "util", { into = pkg }) ``` ## How it operates 1. **Registration.** Writing `package.yaml` indexes the package with the package registry. 2. **Install.** A package is read before it is installed: `zm ls-tree @<guid>` in the shell lists what it contains, and `zm show @<guid>:<path>` and `zm grep <pattern> @<guid>` read it, so candidates are compared by what they do. The one that fits is installed by guid, the way every asset is: `zeromind.install` from the shared library, or `tools.use("zeromind", "installAsset", <guid>)` inside the engine. The install writes the package's files (and the deps its `.refs` record) into this world's source, so its sub-assets resolve here. `tools.use("zeromind", "preview", <guid>)` reports what an install would write before it writes anything. 3. **Sub-asset resolution.** Because `.package` collapses in identity, sub-asset paths work whether the package folder is suffixed (`<Name>.package/<sub>`) or not (`<Name>/<sub>`). 4. **Updates.** `tools.use("zeromind", "updates")` lists installed content with upstream changes available; re-running the install pulls them. 5. **Metadata.** `package.yaml::name` overrides the folder stem if present (rare); `version` + `description` are surfaced in the package index. 6. **Hot reload.** Edits to sub-assets hot-reload as normal; edits to `package.yaml` reload package metadata. ## Discovery - `asset.list("package")` — every registered package. - `asset.inspect("<name>")` — sub-asset catalog, source, this type README. - `ls /zero/source/<Name>.package` lists the files the folder holds. - `library.list()` — the library assets registered in this world, including the ones packages under an installed library contribute. ## Authoring conventions - Name packages by their feature (`voxels`, `cameraRigs`, `dialogSystem`), not by their owner — the host library is the `@<lib>::` scope prefix. - Cluster cohesive sub-assets together. A "character controller" package should ship the controller component, its preset configs, its supporting modules, and a demo scene — not just the component. - Document the package's surface in `README.md` — what install installs, what each sub-asset does, how they relate. Agents reading `cat /zero/source/<Name>.package/README.md` get the full picture in one shot. - Version conservatively — breaking a package means breaking every world that installed it. Use semantic version bumps. ## Common pitfalls - **Missing `package.yaml`.** Without it the folder isn't recognized as a package even if it has a `.package/` suffix. - **Dependency installs.** A package that depends on another package must declare it (in `package.yaml::dependencies` once supported) and the parent world must install dependencies in order. - **Identity collapse.** Sub-asset paths skip the `.package` suffix. Don't include `.package` in `require` strings. ## Referencing inside a package: the `~` rule `~` means **this package**, resolved structurally from the referring file's own location. It is the only package-specific syntax. It is rename-safe (no package name is ever written) and library-agnostic (it never names the host library, so it survives `@builtin` being renamed or the package being mounted elsewhere). `~.tail` maps to `<pkg>.package/tail` from anywhere inside the package. | Referencing from | Use | Example | |---|---|---| | An asset/module **inside this package** | `~.tail` | `require("~.modules.myThing")`, `shader: "~.shaders.myThingBlend"` | | A **different package** under the same root | root-relative identity | `require("@root::systems.otherPkg.modules.x")` | | A **different library's** public API (a deliberate external dependency) | `@otherlib::…` | `require("@physics::modules.rigidbody")` | **Never write `@builtin::` (or any host-library name) to reference your own package** — that couples the package to the library it currently lives in and breaks if the library is renamed or the files move. Use `~.`. It works from Luau `require()` and from asset-reference strings in data files (a material's `mat.yaml` `shader:` field, a component's default asset ref); the engine resolves it against the referring file's own package. `asset.resolve` takes a `~.` name the same way, against the package of the file that calls it: `asset.resolve("~.materials.myThingStandard", "material")`. `~.` is the package-relative self-reference; `@root::` is the root-relative one (the library root, or the world source tree for non-library content). The `core/requiring-modules` guide covers the root model in full. A package that ships substantial functionality should include a `.guide` asset (a `<name>.guide/guide.md` folder anywhere under the package); the `guides` tool discovers it live, so collaborators find the system the way they find built-in guides. A package's test suite reaches the shared `Test` framework through its enclosing asset: `local Test = asset.containing(__FILE__).modules.shared` (`asset.containing` returns the deepest enclosing typed asset, the `.testSuite`, not the outer `.package`). ## Related types - Any of `.component`, `.module`, `.scene`, `.preset`, `.tool`, `.toolbox`, `.material`, `.shader`, `.style`, `.service`, `.importer` — packages bundle these. ## In the Inspector Package: the name, version and description package.yaml declares, how many things the package holds and what they are, and **Open** for package.yaml. A package.yaml that does not decode is listed under Problems with the decoder's error.
▲ 0↑ born
·
assettype · born here
❒asset
# assetType (asset type) The `assetType` is the **type of types** — the meta-type that every `<typename>.assetType/` folder is an instance of, including itself. A `<typename>.assetType/` folder declares a new asset type called `<typename>`: its on-disk shape, its identity rule, and the structural contract instances of the type must satisfy. This type is self-hosting. `assetType.assetType/` conforms to its own schema — a `type.yaml` plus a `README.md` — so the type system is defined in terms of itself rather than a hidden engine special-case (the same way a C compiler is written in C). Every asset in the engine, including each `.assetType` definition, now resolves to a registered assetType: a `.material` resolves to `material.assetType`, and `material.assetType` resolves to `assetType.assetType`, which resolves to itself. ## Why it exists Assets used to be mapped to their type *implicitly*, by matching the folder suffix against a name-keyed registry. That match carried no stored link: nothing on a `Foo.material` recorded *which* `material.assetType` it was written against, so two worlds shipping a same-named type could silently disagree. Making `assetType` a real, resolvable asset closes that gap — the link from an asset to its type is now an explicit reference pinned in the asset's `.refs` sidecar (`via: "asset_type"`), exactly like every other dependency. ## Where it lives - Source: `/zero/source/.../<typename>.assetType/` - Identity: `<typename>` (the `.assetType` suffix strips from the identity; the folder retains the suffix on disk). - Folder shape: - `type.yaml` — the structural spec the validator reads. **Required.** - `README.md` — type-level documentation. **Required.** - `about.yaml` — three short plain-language texts saying what the type is for (see "Say what your type is for" below). Every built-in type ships one. - `behavior.luau` — optional behavior/scaffolding module. - `template/` — optional canonical placeholder body the templater copies into a new asset of the type. - `<name>.module/` — optional shared code the type ships to **every instance** of it, conventionally `shared.module/` (see below). ## Shared code its instances reach (`asset.containing` + `.modules`) A type ships code every instance of it uses by declaring the modules in its own `behavior.luau`, as a map of tracked `require`s: ```luau -- inside <typename>.assetType/behavior.luau M.modules = { shared = require(".shared") } -- the sibling shared.module/ ``` An instance reaches it with one location-independent line: ```luau -- inside any foo.<typename>/init.luau local api = asset.containing(__FILE__).modules.shared ``` `__FILE__` is the chunk's own VFS path; `asset.containing` resolves the calling asset, and `.modules.<name>` follows the instance's pinned `typeRef` (the type's **guid**, recorded in the instance's `.refs` as `via = "asset_type"`) to the module its type declares. Because resolution follows that link rather than a category name, the same line in an instance of a different type resolves to that type's module, and identically-named modules in two types never collide. `asset.typeRef(target)` reads that guid for any target; `asset.resolve(asset.typeRef(target))` gives the full `AssetRef<assetType>`. See `assetTypes/README.md` § "Shared code: type-owned modules" for the full mechanism. ## How to create one Authoring a new asset type is just creating a `<typename>.assetType/` folder under `/zero/source/`. The engine registers `<typename>` as a category **the moment the folder is written** — no engine restart, no Rust change, and it works for built-in (`@builtin/assetTypes/`) and user-defined types identically. Once `<typename>` is registered, `<name>.<typename>/` folders are recognised as assets of that type and resolve their `asset_type` link back to this definition. Creating a `<name>.<typename>/` folder *before* its `<typename>.assetType/` exists fails fast: the suffix has no registered type, so the folder can't be a valid asset. Author the type first. ## Say what your type is for (`about.yaml`) Every type carries a small `about.yaml` beside its `type.yaml`, and the README you write for the type documents how to author an asset of it. Both are required, and both are meant to be written as you make the type, while you know what it is for. `about.yaml` answers three questions in plain words, one key each: ```yaml intent: "Sets the typeface of the text in your game: UI, labels, signs and 3D lettering." function: "A font, from a TTF, OTF or WOFF file, made available to all of Zero's text, including 3D text." usage: "You choose it by name for your UI or your text, and the text appears in that typeface." ``` - **`intent`** is what the type is for in someone's game. Name the whole range of things it covers, in the words a game maker would use for them. - **`function`** is how it works today: what an asset of the type is made of and what the engine does with it. - **`usage`** is what a person or an agent does with one: how they pick it, place it, call it, or hand it to something else. A few habits keep it useful. Write about the type as a whole, so the text holds for every asset of it, and leave out any one asset's name. Say what the type does right now. Stay with words a game maker would say, and mention a file name or a call only where a game maker would know it. The limits are in bytes: `intent` 800, `function` 1500, `usage` 1200. Each is generous, and a few sentences usually say it. Why bother: agents working in a world read these three texts to learn what your type is for, so they reach for it when it fits and use it the way you meant. People searching find the type by what they want to make, and the three texts let them tell at a glance whether it is the one they need. `asset.create("assetType", name)` scaffolds the file with all three keys, and a README whose first paragraph is a `[[describe]]` note. Each value starts with `[[describe]]` followed by a note on what to write; replace the whole value with your own words. A value or a README line that still starts with `[[describe]]` counts as not written yet, which the push's check of each asset it carries against its type reports, naming the file. A type you leave as it was is not asked, so older types stand. Many types at once, or one you would rather not write by hand: `services describe` lists the types that lack these texts and what filling them costs, and `{ mode = "all" }` writes them into the same files, where you edit them like anything else you wrote. Where the texts show up: `asset.inspect("<typename>", "assetType")` returns them as `detail.about`, `assetTypeRef:getAbout()` returns them as a table with the keys `intent`, `function` and `usage`, the type's `types/<typename>` guide page ends with an "About this type" section holding them, and the Inspector's Type section lists them at the top. The guide page is this README plus that section, so a README that explains the type well and an `about.yaml` that says what it is for read well together. ## Ask your assets to say what they are A type can ask each of its assets to carry a description: a file, written by the author or generated, that says what that asset is and what it is for. The people and agents who find an asset read it to decide whether it fits, and the author knows what it is for at the moment of making it. **1. Declare the file with a `described` check.** This is the whole requirement: ```yaml files: required: - path: "README.md" # any file name works kind: "file" content: - { check: "described", message: "Say what this widget is for and where it goes." } ``` With that in place: - An asset whose file is missing, empty, or still has a line starting with `[[describe]]` does not meet the type's requirements. `asset.validate` names it, `zm status` and `world.publishBlockers()` list it, and a push that carries it is refused with `message`. - `services describe` finds it (`mode = "find"` lists it and the price) and can write the description for it (`mode = "all"`). It writes only this file. For a YAML file that holds several texts, name the keys and their limits in bytes, the way this type does for `about.yaml`: ```yaml content: - check: "described" keys: - { key: "intent", max_bytes: 800 } - { key: "usage", max_bytes: 1200 } message: "Write intent and usage." ``` `headings` names sections a Markdown file must have. `assetTypes/README.md` has the full check. **2. Optionally, steer what gets generated with a `describe:` block:** ```yaml describe: guidance: "Say what the widget shows, what input it reacts to, and where it fits in a UI." examples: - "<identity of an asset of this type whose description reads well>" ``` `guidance` says what a description of your type covers, and `examples` names assets whose descriptions show the shape you want. Without the block, generation still works from the general describing guide and the check's `message`. An example is an asset of your type in the library whose description is a true, written one, never a scaffold's placeholder, and never the asset being described. When the library has none, leave `examples` empty rather than name one that is not. A type whose assets the model cannot perceive (a sound it cannot hear, a motion it cannot see) says so in `guidance`: the description comes only from what the asset's own files state, such as its name and the `.metadata` where a generation service records the prompt that made it, and when they state nothing the model answers `cannot` with that reason. `services describe` then fails that one asset with the reason and leaves its file as it was. **3. Start the file unwritten in your template.** Put a heading and one line that starts with `[[describe]]` and a short note on what to write, in `template/<file>`, so a new asset starts unwritten rather than with text that only looks written. Do not put example content in it: every new asset carries the template verbatim, and the same text on many assets makes them impossible to tell apart in search. Types whose assets say what they are through their own content (a mesh, a texture, a sound) leave this out: declare nothing and nothing is asked. ## The `behavior.luau` contract `behavior.luau` is the type's code. It returns ONE table, and the keys below are the ones the framework reads off it for EVERY type — the surface you get by declaring them. Anything else on the table is your own: reachable by name through `require("@builtin::assetTypes.assetType.shared.ref").loadTypeModule("<typename>")` for a system that knows your type (`computeShader` publishes `dispatchKey` that way), and never called by the framework. ```luau local M = {} M.ref = { ... } -- methods on every AssetRef of this type M.modules = { ... } -- shared code the instances reach M.global = {} -- reserved key; ship the empty table function M.onCreate(name, opts) ... end return M ``` ### The tables | Key | Shape | What the engine does with it | |-----|-------|------------------------------| | `M.ref` | `{ [name] = function(self, ...) }` | Every function becomes a method on every `AssetRef` of this type: `ref:name(...)`, with `self` the ref. This is how a type gives its instances an API. Two names in here are contracted — see below. | | `M.modules` | `{ [name] = require(".sibling") }` | Shared code the type's instances reach through `asset.containing(__FILE__).modules.<name>`, resolved by the instance's pinned `typeRef` guid. See the section above. | | `M.refShapes` | `{ [method] = "TypeName" }` | Declares the result type of a `M.ref` method so the LSP can check what a call site does with it. See the `M.refShapes` section below. | | `M.events` | an events schema | The events an ASSET of this type fires, declared the way a component declares its own. Readers subscribe through `ref.events.<name>:connect(...)`; firing authority stays with the type. Keyed by the asset's guid, so every resolver of the same asset shares the signals and a re-resolved ref re-attaches to subscriptions already there. | | `M.namePattern` | a Lua pattern string | The name shape `asset.create` enforces for instances of this type, replacing the default `^[A-Za-z][A-Za-z0-9_]*$`. | | `M.global` | `{}` | Reserved. Ship the empty table. | ### The lifecycle hooks Each is optional; a type that omits one costs nothing. The engine calls them: | Hook | Signature | When it fires | |------|-----------|---------------| | `M.onCreate` | `(name, opts) -> { [filename] = contents }` | On `asset.create("<type>", name, opts)`. Returns the files that scaffold the new instance, as a map of relative path to contents; the framework writes them. The `opts` type annotation is the schema `asset.create` validates the caller's arguments against, so annotate it. | | `M.onReplace` | `(dest, files) -> { [filename] = contents }` | On an `asset.create` that writes over an asset already at `dest`, after `onCreate`. Receives the files `onCreate` produced and returns the files to write, so a type that re-derives its content (a rig re-imported from its source file) can carry what an author added to the asset it replaces. Optional. | | `M.onRegister` | `(self)` | Once per instance, the first time it registers: on `engine.onWorldLoaded` for instances already in the world, immediately on a live `asset.create`, and when an instance arrives after that sweep (installed, pulled, or synced from a peer). Guarded by the instance's path, so a double trigger never double-registers. An instance that leaves the runtime releases the guard, so one arriving at the same path again registers. This is what makes "write an asset into the world and it takes effect live" work for content that registers into a runtime registry. The `@builtin` library is excluded from the sweep. | | `M.onChange` | `(ref, change)` | On every VFS write to a file INSIDE one of this type's instances. `change` is `{ path, asset, type, kind, origin }`: `kind` is `"edited"` or `"seeded"`, `origin` is `"local"` or `"remote"` (an importer runs on the originator only). **Filter on `change.path`**: the hook fires for any file under the folder, so act on the one you care about. It runs as its own task and may wait on what it reads: a `vfs.read` of bytes only the blob store holds (a binary file on the web build) fetches them. The writes to one asset reach the hook in the order they were made, each once the hook before it has finished. A re-entrancy guard suppresses writes back into the same asset routed inline from the hook, and a write the hook makes is routed on a later frame like any other, so the hook must be convergent on its own: diff the meaningful state and short-circuit while your own regeneration is in flight. | | `M.onDelete` | `(ref)` | When an instance of this type leaves the runtime: it is deleted, or the engine swaps away from the world binding that held it. Its files are gone from `/zero/source` by then. A removal routed while the instance's `onChange` is waiting reaches `onDelete` once that hook, and every write queued behind it, has finished. On a swap the instance still exists in the world it belongs to, and a VFS write the hook makes reaches the world now bound. | | `M.validate` | `(assetRef) -> { { code, message, severity? } }` | On `asset.validate`, after the structural `type.yaml` check, for the type's own SEMANTIC validation. `severity` defaults to `"error"`; error-severity problems flip `ok` to false, warnings do not. A hook that raises or returns a non-table is itself reported as a `validate.hook_failed` error. `world.push` runs this per user asset, so declaring it enforces your type's rules at publish time with no further wiring. | ### The two contracted `M.ref` names Most `M.ref` methods are your type's own surface: name them what you like, return what you like. Two are read by the engine and mean the same thing for every type, so their shape is fixed: - **`instantiate(self, target?, opts?) -> (root, idMap)`** — makes an instance part of the scene. Defining it is the whole opt-in: `ref:canInstantiate()` is true exactly when it exists. Its **return is a contract** — see the next section. - **`inspect(self) -> detail`** — the type-specific half of `asset.inspect`. See its section below. ### Names a type cannot shadow Some keys resolve on every `AssetRef` before per-type dispatch, so an `M.ref` entry of the same name is never reached: `canInstantiate`, `getSource` / `getBytes` / `getText`, `exists`, `deps`, `meta`, `runtime`, `events`, `modules`, `typeRef`, and the residency flags `has_backing_asset`, `has_runtime_changes`, `cpu_resident`, `gpu_resident`. These are the behaviours every asset must expose identically, which is why they win. ### Reloading A `behavior.luau` edit ripples to existing refs with no restart: dispatch reads `mod.ref` fresh each time and `require`'s cache is re-run in place. A type whose folder appears at runtime is picked up on the next dispatch — negative results are not cached either. ## The names your type's assets hold (`references:`) An asset that names another asset by a string depends on it: the named asset travels with it into a pack or a pull, and a name nothing answers to is an unresolved dependency `worldValidation` reports and the push gate refuses. The engine reads `require(...)` and the asset lookups on its own; a name your type's format or API carries anywhere else is one you declare, in `type.yaml`: ```yaml references: # A top-level key of this type's own files that holds a name (or a table of them). keys: - { key: "shader", type: "shader" } # A call's argument, counting from 1, wherever the call is written. args: - { call: "g:node", position: 2, type: "procNode" } # A field of the options table a call takes. fields: - { call: "fx.beam", field: "material", type: "material" } ``` `type` is the category the name resolves in, one of `asset.categories()`. An argument declared once per category, at the same `call` and `position`, takes the name from whichever category answers. A key or a field holds one category: declared again with another `type`, it takes the later one. The block is declared as the type registers, when the library or the world holding it is laid into the VFS, so every reference table reads it whatever script has or has not run. `asset.declareReferenceKey`, `asset.declareReferenceArg` and `asset.declareReferenceField` declare the same things from a script, for a surface that is no type's. ## What search does with your type (`indexing:`) A type declares what search embeds for its instances. **This is not optional and there is no useful default**: ZeroMind runs your declaration and adds nothing of its own, so anything you do not point at is absent from the index — silently, and for every instance of the type forever. Twenty types once shipped with no `indexing:` block and every instance of them was unfindable. A type whose instances are never searchable (a run record, a log) says so with `indexing: none`; every other type declares a recipe. A type without an `about.yaml` beside its `type.yaml` refuses every push that carries it, whether or not the push edited the type. Texts in it that are not written yet follow the rule every `described` file follows: they refuse the push that edits, stages or commits the type. Two words carry the whole thing, and they are the same two the search tool exposes: - **`identity`** — what an instance IS. For anything you can look at, that is the picture (`content:` + `modality: image`, matched directly by a text query). For everything else it is the authored text that says what it is. - **`capability`** — what it DOES and how it is made. Code, settings, the model-written summary of them. A type with no behaviour has no `capability` entry. A type with nothing to look at has no image. Leaving a slot empty is a statement; filling it with whatever happens to be lying around is not. The one you have to decide when authoring a type is: *for an instance of this, what is the thing a person would recognise it by, and what is the thing it does?* A texture answers "the image" and "its compression settings". A module answers "its README" and "its code". Write those two answers into `indexing:` and the rest follows. Three specifics worth knowing before you write one: - **`derive` is instructed by this type's own `README.md`.** A model reads an instance's source and writes a description; what it embeds is the description, not the source. It follows your README to know what it is looking at, so a type whose README says what its instances are gets good derivations for free. There is no prompt to name. - **`derive` loses detail.** It keeps the main technique and drops secondary ones. If the source is code you want searchable by its own vocabulary, declare the same files a second time as `capability` with `extractor: verbatim`. Two entries, same role, different failure modes. - **Never `source: { field: name }`.** A filename is one or two words with no usable embedding, and a corpus indexed that way answers "language runtime" with `say_runtime_2.soundClip`. `facets:` is the second stage: a `file:` embedded whole (`.metadata`, so keys nobody declared still get indexed) plus computed keys that state a value for every instance including `false` — the raw file can imply "not rigged" and can never say it. The full schema, every extractor and the facet sources are in [`assetTypes/README.md`](../README.md); the scaffolded block with inline guidance is in [`template/type.yaml`](template/type.yaml). ## What your type looks like (`preview:`) A type declares how its instances are pictured, and the engine makes the picture when something needs it: staging an instance whose `preview.png` is missing or pictures content it no longer holds queues a render, a view that shows the instance asks for one, and `ref:preview()` renders one on demand. An edit renders nothing on its own. Each `preview.png` records the content it pictured, which is how a stale one is told from a current one. The preview studio owns the light, the backdrop, the framing and the camera, and nothing in the world reaches it, so an instance pictures the same in every world. Declare one of four recipes: | `kind` | The picture is | Your type provides | |---|---|---| | `instantiate` | the asset stood in the studio through its own `instantiate` | `ref.instantiate` | | `image` | a file the instance holds, fitted to the frame, nothing rendered | `source.files`, the candidate files in order | | `surface` | the asset painted on a builtin mesh | `mesh`, and `ref.previewMaterial(self) -> { material }` | | `custom` | whatever `ref.preview(self, opts)` returns | `ref.preview` | ```yaml preview: kind: surface mesh: sphere ``` A scene-instantiable type that declares nothing still answers `ref:preview()` through its `instantiate`, but persists no picture: the declaration is what makes the engine keep one. List `preview.png` among the type's files so an instance may hold it. The picture is findable only when `indexing.content` names it. The two are separate statements, and neither is derived from the other: ```yaml indexing: content: - role: identity modality: image source: { file: "preview.png" } ``` `@builtin::assetTypes.assetType.shared.previewRecipe` reads the declaration; the scaffolded block with inline guidance is in [`template/type.yaml`](template/type.yaml). ## Typing a result from the instance (`M.refShapes`) Every method on `M.ref` is checked against the type its `--!return` names, and every asset of the type gets the same one. That is right for most methods and wrong for the ones whose result is shaped by the ASSET: a method answering one entry per child folder, per row of a config, per binding an input map declares. The widest true annotation for those is `{ [string]: Thing }`, and an indexer accepts every key — so a caller's typo reads as valid and nothing reports. `M.refShapes` lets the type state the result per instance. Each entry is `function(self) -> (typeExpression, source?)`: a Luau type expression for THIS asset, and the module whose type vocabulary the expression uses. Read the asset; never run it — stating a type must not take effect. ```lua -- inputMap.assetType/behavior.luau — the shipped example. -- `activate()` hands back one handle per control the map declares. M.refShapes = { activate = function(self): (string, string) local names = {} for _, control in ipairs(M.ref.controls(self)) do table.insert(names, control.name .. ": Handle") end if #names == 0 then return "", "" end return "{ " .. table.concat(names, ", ") .. " }", "@builtin::modules.zinput.scheme" end, } ``` With it, `map.jump:onPressed(fn)` resolves and `map.noexisting` is reported with the map's real controls. Without it, both pass silently. ### What the call site has to say A per-instance type is matched by the asset's IDENTITY, so it applies only where the reference the method is called on says WHICH asset. Two spellings do: ```lua -- a component field, whose declared default names the asset public = { map = Field.assetRef("inputMap", "@builtin::inputMaps.default", Sync) } local controls = public.map:activate() -- typed for THAT map -- an annotation, naming category and identity local m: AssetRef<"inputMap", "@builtin::inputMaps.default"> ``` `asset.resolve("<identity>", "<category>")` carries the CATEGORY, so the methods the category defines are checked on the result — but not which asset it found, so a per-instance result keeps the category's declared return. A reference that names no asset (`asset.resolve(someVariable)`, a value passed in as a parameter) carries neither, and calls on it are unchecked. That is the same rule everywhere: the checker states what the code states, and a name computed at runtime states nothing. ### The result is a value, not a binding The type belongs to the expression, so it travels the way any inferred type travels. Indexing the call's result is checked; stashing it in a module local and indexing it from another function is not, because the local's declared type is what carries across — and `any` (or no annotation) carries nothing: ```lua -- checked: `d.greting` is reported function awake() local d = public.dialogue:lines() d.greeting:say() end -- NOT checked: `lines` is a module local typed `any` local lines: any = nil function awake() lines = public.dialogue:lines() end function start() lines.greting:say() end ``` Bind what you need at the call site and the names stay checked in both places — the asset-derived type at the boundary, ordinary scoping after it: ```lua local lineGreeting = nil function awake() local d = public.dialogue:lines() lineGreeting = d.greeting -- `d.greting` is reported here end function start() lineGreeting:say() end ``` ### When nothing is reported and you expected something From a call site, a type that is correct and a type that was never published look the same: no diagnostic either way. `require("@builtin::assetTypes.assetType.shared.refShapes").published()` answers which it is. It returns two maps — the category surfaces, and the per-asset results keyed by identity and then by method: ```lua local shapes = require("@builtin::assetTypes.assetType.shared.refShapes").published() shapes.categories.dialogue --> "{ lines: () -> any, lineNames: () -> { string } }" shapes.returns.Shopkeeper.lines --> "{ browsing: Line, farewell: Line, greeting: Line, wares: Line }" ``` An identity absent from `returns` was never published for, which is a different thing from published-and-correct — and the reason to look here rather than at the call site. The answers are recomputed when an instance is written, so a control added to a map reaches the checker with no restart. Only types that declare `refShapes` are read per instance, and at most 64 assets of one type are — past that the type keeps its declared return and the engine log names what was dropped. ## The instantiate hook (`M.ref.instantiate`) — a contracted return A type opts into becoming part of the scene by defining `instantiate` on its `ref` table. That is the whole opt-in: `ref:canInstantiate()` is true exactly when the hook exists, so consumers offer a scene path — an `Asset.source` field, a viewport drop, a tool argument — by CAPABILITY rather than by a list of type names. Unlike `inspect`, this hook's **return is a contract**. A caller writes one piece of code against every instantiable type, so what comes back cannot vary by type: ```lua -- behavior.luau local Instantiable = require("@builtin::assetTypes.assetType.shared.instantiable") local M = {} M.ref = { instantiate = function(self, target, opts) local root = Instantiable.root(self, target, opts) -- IN: the base opts -- ... compose whatever this type is, under `root`, NOW ... return Instantiable.result(self, root, idMap) -- OUT: the contract end, } return M ``` **IN.** `target` is an owning entity ref: the instance lands under (or, for a hierarchy type, onto) that owner. With no target the type spawns a fresh root. `position`, `rotation`, `scale`, `name` and `temporary` mean the same for every type — `Instantiable.root` applies them to a root you mint, `Instantiable.place` to one you adopt, so you never re-read the spec. Honour more opts of your own if your type needs them, and document them in the hook's `--!arg` docs. **OUT.** Return `(root, idMap)` through `Instantiable.result`: - **`root`** — the composed root, as an `EntityRef`. Composition is **synchronous**: everything your type builds is live when you return, so the caller can parent to it and read its components in the same statement. Do not defer the work to a component's `awake` and return an empty shell — a root that is not live is refused. - **`idMap`** — the `originalId -> runtimeId` map naming what you spawned, or nil for a type with no addressable children (`result` normalises it to `{}`). **A caller never receives nil.** A component that re-composes your asset on every load keeps this map and passes it back in, which is how a cross-entity reference into the composition survives a reload. `AssetRef` runs every `instantiate` through `result` on the way out whether or not your type called it, so returning something else fails at your own call rather than handing a caller a nil root. Calling it yourself is still how you say what you return, and running it twice changes nothing. `@builtin::assetTypes.assetType.shared.instantiable`'s README is the full reference for both halves. ## The inspect hook (`M.ref.inspect`) `asset.inspect(ref)` resolves `ref`, builds a common envelope shared by every asset (`identity`, `name`, `guid`, `source`, `typeName`, `typeDefinitionPath`, `scope`, `origin`, `description`, `tags`), then calls the resolved type's own inspect hook — `M.ref.inspect(self)` on `behavior.luau` — for the type-specific `detail`. The hook returns *only* `detail`; the framework fills in everything else: ```lua -- behavior.luau local M = {} M.ref = { inspect = function(self) -- self.path is the asset's own folder. Parse the asset's OWN -- files — source text, a native payload header, its `.metadata` -- — never runtime state (a live component instance, a GPU -- upload, a compiled schema): inspect must work on an asset that -- hasn't been compiled, uploaded, or registered yet. return { -- type-specific fields, whatever this type wants to surface } end, } return M ``` A type that omits `M.ref.inspect` yields the generic record: the envelope alone, `detail = nil`. That's a valid, working state — not an error — but it means agents calling `asset.inspect` / `tools.use("assets","describe", ...)` on an instance of the type see only identity and scope, none of what makes an instance of it meaningful. Declaring the hook is what makes a type's instances inspectable for what they actually are. A hook that raises leaves `detail = nil` and sets `record.warning` to the error text — `asset.inspect` itself never raises for a resolved ref. ## Discovery - `asset.list("assetType")` — every registered asset type. - `asset.inspect("<typename>")` — the type's identity, source path, and this README, plus its `about.yaml` texts as `detail.about`. - `asset.resolve("<typename>", "assetType")` — the `<typename>.assetType` asset itself (used by the validator to find the type's `type.yaml`). ## Related - `assetTypes/README.md` — the full `type.yaml` schema and validator rules. - `@builtin::assetTypes.assetType.shared.instantiable` — the instantiation contract's shared implementation, both halves. - Every other `*.assetType/` here — the concrete types built on this meta-type. ## The Inspector a type ships Every type names the file its Inspector sections come from with `inspector: { type: "inspector.luau" }` in its type.yaml. `asset.create("assetType", name)` scaffolds the block and an `inspector.luau` whose Contents section lists an asset's files, built from `ctx.parts`. `asset.validate` on a type that names none reports `assetType.inspector_required` (an error for a `@builtin` type the engine's library authors; a warning for a world's own, and for one the library carries by reference, which the world that published it answers for), and one whose file does not load `assetType.inspector_invalid`. ## In the Inspector Type: what the type.yaml declares (suffix, container, primary files, the settings and inspector blocks), whether the type ships a behavior, how many instances it has, and **Open** for its files. Schema: the settings every instance carries, as read-only typed controls at their defaults (a type whose schema is per instance says where it is declared). Instances: a link to each of the first 20 assets of the type, and how many there are when there are more.
▲ 0↑ born
·
assettype · born here
❒asset
# Component (asset type) A component is a reusable behavior + data unit attached to an entity. The component file declares public fields, methods, and lifecycle callbacks; the engine instantiates one per attachment and drives its hooks every frame. Components are the primary unit of gameplay code — movement, AI, animations, UI panels, network sync, and per-entity state all live here. ## When to use one - You need per-entity state that survives across frames. - You need per-entity behavior driven by engine lifecycle hooks (`start`, `update`, `destroy`). - You want to expose configurable fields to designers / `entity.spawn` callers / scenes without code changes. If you need a global service that outlives any entity, use a `.service` instead. If you need a stateless pure-function library, use a `.module`. ## Where it lives - Source: `/zero/source/.../<Name>.component/` - Identity: `<Name>` (the `.component` suffix strips from the identity, but the folder retains the suffix on disk). - Folder shape: - `init.luau` (or `init.lua`) — declares the component. **Required.** - `README.md` — instance-level documentation. **Required.** - `.metadata` — agent-editable tags + free-form fields. **Required.** The structural contract is enforced by `asset.validate` against this type's `type.yaml`. ## How to create one Always start with the scaffold — it guarantees the folder shape matches `type.yaml` from the moment it's on disk: ```luau asset.create("component", "<Name>") -- Creates: /zero/source/<Name>.component/ -- init.luau (canonical declare + public block + hooks stubbed) -- README.md (instance README template) ``` After scaffolding, edit `init.luau` to fill in the actual fields and behaviour. The component registers as soon as the file is written — hot-reload is automatic; no engine restart required. ## How it operates 1. **Registration.** Writing `init.luau` into a `.component/` folder indexes the asset, mints a guid (in the `.meta` sidecar), and surfaces the type to the engine's component registry. Same-frame visible to `entity.component.add`. 2. **Attachment.** `entity(id).component.add(name, data)` (or the batch form `tools.use("entityOps", "addComponents", targets, { [name] = data })`) attaches an instance. The `data` table's fields override the component's declared defaults; omitted fields take the declared default. The `data` values are copied into `public.*` BEFORE `awake()` runs. 3. **Lifecycle.** The hooks run in this order: - `awake()` — same frame as attach, immediately after the data copy. Initialise state, set up native bridges, register with other systems. Other components on the same entity may not have awoken yet. - `start()` — the next frame. Every component on the entity has completed `awake()` by then, so cross-component setup is safe here. - `update(dt)` — same frame as `start()`, then every frame after, for as long as the gameplay clock is running. `fixedUpdate(dt)` runs beside it on the fixed timestep; `editorUpdate(dt)` runs while the clock is held. Which of them ticks is decided by `engine.mode` AND `engine.paused` together — see **Per-frame hooks and the two axes** below, and `man modes`. - `onDestroy()` — when the component is removed via `component.remove` or when the owning entity is despawned. Release resources, drop native ECS components. For the full list (`onEnable`, `onDisable`, `fixedUpdate`, `editorUpdate`, `onPropertyChanged`, `onAssetReload`, `onModuleReload`, `onBeforeSave`, …) and the timing details, **read `man components` — that is the canonical lifecycle reference. Generic component patterns (declare blocks, public fields, sync, ECS bindings, typed methods) all live there.** 4. **Public surface.** Fields declared in `public` are inspectable, editable in the inspector, and persist with the entity. Methods are declared on that same table with a colon — `typed function public:method(args)` — and every one of them takes the component ref as `self`, so a caller invokes it with a colon too: `entity(id).component.get(name):method(args)`. That is the one calling convention a component ref answers to: the reflection methods `ref:fields()` / `ref:getField(name)` / `ref:setField(name, value)` take the same spelling. A function the chunk declares at file scope (`function helper()`) is the component's own, reachable from its hooks under its bare name; `public` is what the ref carries. 5. **Settings and the Inspector card.** `type.yaml` names the component's settings schema as `settings: { schema: { instance: "init.luau", export: "public" } }`: the `public` table IS the schema, and its values live on each entity the component is on, so `asset.schema(componentRef)` answers the Field descriptors and no values file exists. On an entity the Inspector shows the component as a card whose generic `settings` section edits those fields as typed controls, each edit checked against the schema and written to the live component as one undoable operation. `type.yaml` also names `inspector: { instance: "inspector.luau" }`: a component that ships an `inspector.luau` beside its `init.luau` shapes its card — the file returns `{ sections, replace, order }` (see `assetTypes/README.md`, "`inspector:`"), each section's `build(ctx)` reading `ctx.component` (the live component) and `ctx.selection.id` (the entity). `SceneModule.component/inspector.luau` replaces `settings` with the bound module's declared inputs. 6. **Events.** An `events` block declares typed moments the component fires from inside — `events = { onHit = Event({ dmg = Field.number(0, NoSync) }) }`. The owner fires with `events.onHit:fire({ dmg = 10 })`; outside code subscribes through the proxy's subscribe-only facade, `entity(id).component.get(name).events.onHit:connect(fn)` (`:once` / `:wait` too). Pass `Sync` as the event's second argument to replicate a fire to other peers. Events let one component react to another's moments without polling. Full reference: **`man components`**, Events section. 7. **Hot reload.** Editing `init.luau` reloads the component definition; live instances are reconciled with the new declaration in-place, and each crosses one lifecycle boundary: `onDestroy()` ends the running life, then `awake()` and `start()` construct the one that replaces it, and `update()` resumes on the next frame. `public` and `private` values carry across; module-level `local`s belong to the chunk, so the new life starts with them at their initial values and its `awake`/`start` are what fill them in. Whatever the old life handed elsewhere — a subscription, a shared registration, a spawned entity — is released in `onDestroy`, the call that pairs with the construction about to run. An edit that leaves the source byte-identical crosses no boundary at all. A field carries its value across when something stated it: a key of the `data` table the instance was attached with (a scene record's data is that table), or a write made since. Every other field takes the edited source's declared default, so changing a default reaches every instance that left the field to it. An instance that states its own value for a field whose default the edit changed keeps that value, and the reload logs a `[HotReload]` warning naming the field, the entity, the value kept and both defaults. ## Discovery - `asset.list("component")` — every registered component (engine + world + libraries). - `asset.inspect("<name>")` — identity, scope, public fields, lifecycle methods, source path, and this type README. `detail` carries the component's declared `fields`, `methods`, and `events`, parsed from its source — `tools.use("assets","describe", "<name>")` renders it as markdown, so a component's events are visible without opening its source. - `ls /zero/source/<Name>.component` lists the files the folder holds. - `entity(id).component.list()` — components attached to an entity. ## Per-frame hooks and the two axes `engine.mode` (`"edit"` / `"play"`) and `engine.paused` (the gameplay clock) are independent, and the per-frame hooks read them separately: - `update(dt)` / `fixedUpdate(dt)` tick while **the gameplay clock is running** — whichever mode the engine is in. - `editorUpdate(dt)` ticks while **the clock is held** — because the mode is `edit`, or because play is paused — and also alongside `update` whenever edit is running. Which gives four states, and this table is the contract: | `engine.mode` | `engine.paused` | hooks that tick | |---|---|---| | `edit` | `true` | `editorUpdate` | | `edit` | `false` | `update`, `fixedUpdate`, `editorUpdate` | | `play` | `false` | `update`, `fixedUpdate` | | `play` | `true` | `editorUpdate` | Entering edit sets `paused = true` and entering play sets it back to `false`, so a session that never writes `engine.paused` only ever sees rows 1 and 3 — which is where the shorthand "`update` is the play tick, `editorUpdate` is the edit tick" comes from. It is the two default rows of a four-row table, not the rule. **Row 2 is the one that surprises.** With `engine.paused = false` in edit — set by hand, or by a tool that advances the gameplay clock to sample it, such as a `capture.collage` given a `duration` — `update` and `editorUpdate` both fire, once each, every frame. A component that declares both and routes them to one body therefore runs that body **twice per frame**: ```luau local function drive(dt) ... end function update(dt) drive(dt) end function editorUpdate(dt) drive(dt) end -- twice per frame in row 2 ``` Nothing reports the doubling; it reads as an animation running at twice its intended speed. To get exactly one tick per frame in every row, ask which axis you meant and answer it in the body: ```luau function update(dt) drive(dt) end function editorUpdate(dt) if engine.paused then drive(dt) end end ``` A scene `entrypoint.luau` declaring the same two hook names is dispatched by the scene loader, not by this table: there `update` needs play AND running, `editorUpdate` needs edit, and play-paused ticks neither. Read `man scenes` before carrying a component's rule to an entrypoint. ## Authoring conventions - Annotate public fields with `--!desc` so the inspector + LSP show documentation alongside the field name. - Declare lifecycle hooks (`start`, `update`, `destroy`) explicitly rather than relying on engine defaults — readers can see at a glance what runs when. - Keep `update(dt)` cheap — it runs every frame per instance. Move heavy work to `start`, to a service, or behind a job (`man jobs`). - For multi-entity batching, prefer `tools.use("entityOps", "addComponents", targets, components)` over a loop of `entity(id).component.add` — it takes many targets and many components in one call. ## Common pitfalls - **Naming.** The component identity is the folder stem with `.component` stripped (e.g. `<Spinner>.component/` → identity `Spinner`). Don't repeat the suffix in the identity passed to `component.add`. - **`init.lua` vs `init.luau`.** Either is accepted (it's a `one_of_group: "entrypoint"`), but `.luau` is canonical. Mixing both in one folder is invalid. - **`update` in edit mode.** It runs whenever the gameplay clock is running, and entering edit stops that clock — so `update` is idle in a freshly-entered edit session and ticks again the moment anything sets `engine.paused = false`. Use `tools.use("wld", "play")` to test update logic under the mode it ships in; `tools.use("wld", "edit")` to return. See **Per-frame hooks and the two axes** above. - **Renaming.** Renaming the folder changes the identity. Update every `component.add(<oldname>, ...)` call and the scene JSON entries that reference it. ## Related types - `.module` — pure-function library, no per-entity state. - `.service` — globally-scoped behavior, no per-entity state. - `.preset` — a captured component configuration, replayable into `component.add` without re-typing fields. - `.bundle` — entity hierarchies that may carry component attachments as part of the bundle. ## In the Inspector Fields: its execution order, hooks, and how many methods and events it declares, then the fields its `public` table declares as read-only typed controls holding their defaults, with **Check** and **Open** for its entry file. Instances: a link to every entity carrying it. What its card shows on an entity comes from the component's own `inspector.luau`.
▲ 0↑ born
·
assettype · born here
❒asset
# Compute shader (asset type) A `.computeShader` is a GPU compute (GPGPU) program with a **zero-scaffolding** authoring contract: you write only `@compute fn main(...)` plus a declarative `bindings.yaml`, and the engine GENERATES every `@group/@binding` declaration — storage buffers, textures, samplers, storage textures, and a `params:` uniform — expands `#include`s, naga-validates the result, and registers it. The single source of truth for the binding layout is `bindings.yaml`; there are no hand-written `@binding` lines to keep in sync. > New to how content lives across the CPU and GPU? Read `core/resource-model` > first: it covers the asset / CPU / GPU forms every resource takes, the upload > path, and how compute fits the performance ladder (`core/performance`). ## Folder shape A `.computeShader` is a folder asset: ``` <name>.computeShader/ shader.wgsl # the WGSL body — only `@compute fn main` + helpers (required) bindings.yaml # the binding + params schema (required) README.md # a one-paragraph description of THIS shader (required) .metadata # asset metadata ``` Each file carries a committed `.meta` sidecar pinning its stable guid. The identity is `<name>` (the `.computeShader` suffix strips). Resolve the shader once, then dispatch through the reference: ```lua local carve = asset.resolve("carve", "computeShader") carve:dispatch({ buffers = { heights }, workgroups = { 64 } }) ``` `asset.resolve` is what records your script's dependency on the shader, so the shader travels with the world your script lives in — packed, pulled, or published. It compiles on first dispatch; there is no setup step. ## bindings.yaml One ordered `bindings:` list — the binding index is the list position (0, 1, 2, …) — plus an optional `params:` scalar-uniform block. `kind` defaults to `buffer` when omitted. ```yaml bindings: - { name: clouds, kind: storage3d, format: rgba16f } # texture_storage_3d<rgba16float, write> - { name: occupancy, kind: texture3d } # texture_3d<f32> - { name: smp, kind: sampler } # sampler - { name: out_tex, kind: storage2d, format: rgba16f } # texture_storage_2d<rgba16float, write> - { name: U, kind: buffer, access: read, element: f32 } # var<storage, read> U: array<f32> - { name: Uniforms, kind: buffer, access: read, element: VolumeUniforms, array: false } # var<storage, read> Uniforms: VolumeUniforms params: - { name: scale, type: float, default: 2.0 } - { name: count, type: int, default: 0 } ``` | `kind` | extra keys | generated WGSL declaration | |---|---|---| | `buffer` (default) | `access: read \| read_write` (default `read_write`), `element: <wgsl type>` (default `f32`), `array: true \| false` (default `true`) | `var<storage, ACCESS> NAME: array<ELEMENT>;` (or `… NAME: ELEMENT;` when `array: false`) | | `texture3d` | — | `var NAME: texture_3d<f32>;` | | `texture2d` | `sample: float \| uint \| sint` (default `float`) | `var NAME: texture_2d<f32>;`, or `<u32>` / `<i32>` for the other classes | | `texture_depth` | — | `var NAME: texture_depth_2d;` — the one sampled shape with no `sample:` of its own; a depth-aspect channel (`@scene.depth`) binds here, not through `texture2d` | | `storage3d` | `format`, `access: write \| read \| read_write` (default `write`) | `var NAME: texture_storage_3d<FMT, ACCESS>;` | | `storage2d` | `format`, `access: write \| read \| read_write` (default `write`) | `var NAME: texture_storage_2d<FMT, ACCESS>;` | | `sampler` | — | `var NAME: sampler;` | | `textureCubeArray` | — | `var NAME: texture_cube_array<f32>;` — the environment cube array, bound to `@scene.environment` by a render feature's pass | | `vertices` | `access: read \| read_write` (default `read_write`) | `var<storage, ACCESS> NAME: array<u32>;` plus the mesh's layout uniform and the `zeroVertex*` accessors | | `acceleration_structure` | — | `var NAME: acceleration_structure;` (hardware ray tracing) | - **Eight storage buffers per shader** — the WebGPU guarantee, and what the engine asks every device for. `buffer` and `vertices` bindings spend that budget; textures, samplers, storage textures and `params:` do not. An `acceleration_structure` brings engine-owned buffers with it — **three** where the device has hardware ray query (the tables `zeroHitSurface` / `zeroMaterial` read) and **five** on the software backend (those, plus the scene triangles and the hierarchy over them) — so a traced pass has three slots of its own on the backend web users get. Over budget, the engine refuses the shader and logs which buffers it declared. - A `buffer` defaults to `array<ELEMENT>`. Set `array: false` for a single NON-array storage buffer — a struct binding (e.g. `element: VolumeUniforms`, `array: false` → `var<storage, read> NAME: VolumeUniforms;`). The struct definition itself lives in `shader.wgsl`, not `bindings.yaml`. - **A sampled texture binds under the class its FORMAT puts it in**, so `sample` states that class and the generated `texture_2d<..>` matches it. An integer channel — `@scene.material`, which is `Rgba8Uint` — is `uint`; everything else is the `float` default. Bind an integer channel to a `float` slot and the engine refuses the pass and names both classes, because the device would otherwise reject the bind group and invalidate the pipeline. - A storage texture's `access` decides both the generated declaration and the layout the engine registers, so the two always agree. It defaults to `write`, the access every target grants every storage-compatible format. `read_write` is granted on `r32f` alone; declaring it on any other format is refused at the compile, with the reason on `compute.programState`. A pass that needs to read a wider target reads it through a separate `texture3d` / `texture2d` binding, or through a second storage binding declared `read`. - `format` tags (storage textures): `r8`, `r16f`, `r32f`, `rgba8`, `rgba16f`, `rgba32f`. Storage textures only allow storage-compatible formats (`r32f` / `rgba8` / `rgba16f` / `rgba32f`). - An `acceleration_structure` binding resolves to the scene acceleration structure when the shader runs as a render-pass with ray tracing enabled (`renderer.setRaytrace(true)`). Declaring one makes the engine emit the helper functions the body can call: `zeroTraceClosest(origin, dir, t_min, t_max) -> ZeroHit` (struct: `hit, t, instance, primitive, bary, attr`, where `instance` is the hit object's render slot), `zeroTraceAny(origin, dir, t_min, t_max) -> bool` (terminate-on-first-hit, for shadow/occlusion rays), and `zeroHitSurface(hit) -> ZeroSurface` (`normal, uv, material`) + `zeroMaterial(index) -> ZeroMaterial` (`base_color, emissive, metallic, roughness`) to shade what a hit landed on. The same surface works on both backends — hardware ray query and the software/compute path — so the shader doesn't branch on `renderer.raytraceCapability()`. Bind it from a render-pass, which is where the scene acceleration structure exists. `params:` declares scalar uniforms (`type: float | int`). When non-empty the engine appends a `ZeroComputeParams` uniform as the trailing binding (read it in WGSL as `params.<name>`) and tunes its values at runtime with `shaderRef:setParam(prop, value)`. ## The kernel ABI: `columns`, counted and appended outputs, `specialize`, `determinism` One shader asset can serve many shapes of data. `bindings.yaml` states the shape with these extensions, and the engine's compile path lowers them to the plain bindings and WGSL above (`modules/compute_abi`, whose README has the whole surface): ```yaml workgroup: 64 # the size the body declares (default 64) bindings: - { name: cols, kind: columns, access: read } # a table of columns in one binding - { name: src, kind: buffer, access: read, element: T } - { name: dst, kind: buffer, access: read_write, element: T, count: "len(src)" } - { name: hits, kind: buffer, access: read_write, element: vec4<f32>, capacity: "n * 4", append: true } specialize: - { name: T, options: [f32, i32, vec2<f32>, vec3<f32>, vec4<f32>] } - { name: FOLD, options: [add, min, max] } - { name: BODY, kind: source, default: "0.0" } # text that replaces the identifier determinism: exact # or float_order ``` - A `buffer` of a scalar or vector element gets `NAME_get(i)`, `NAME_len()` and (unless `access: read`) `NAME_set(i, v)`. A `vec3` element is stored as a flat scalar array, so a column of vec3 keeps its 12-byte stride. `zero_n()` is the dispatch size. - `columns` binds a table of columns (`compute_abi.table`) as one storage buffer; `cols_f32(c, i, lane)`, `cols_vec3(c, i)` and the rest read it. - `count` / `capacity` give the size, in elements, of an output the run allocates. `append: true` adds a live counter binding, `NAME_count`, with `NAME_store(slot, v)`, `NAME_set_count(n)` and, under `determinism: float_order`, `NAME_append(v)`. - `specialize` parameters are compile-time: type options become `alias NAME`, named options become `const NAME` and `NAME_<option>`, `kind: source` text replaces the identifier. Each specialization compiles once. - `determinism: exact` refuses `NAME_append`: an exact output is written at ranks the kernel computed. Dispatch such a shader through its program: ```lua local variant = shaderRef:program():variant({ specialize = { T = "vec3<f32>" } }) local run = variant:run({ n = 64, inputs = { src = srcBuffer } }) local values = require("@builtin::modules.compute_abi").read(run.outputs.dst.buffer) ``` `shaderRef:program()` answers the same program, with the variants it has lowered and compiled, for as long as `shader.wgsl` and `bindings.yaml` hold the bytes they held when it was made. A write to either file, from any origin (`vfs.contentVersion` moves), makes the next `program()` a new one, and a dispatch through it runs the new text. The schema `getBindings()` answers is the one the program was made over. ## Writing a mesh's vertices — `kind: vertices` A mesh's vertices are raw bytes on the GPU, and the engine stores a mesh in one of two layouts: 92 bytes a vertex with every attribute a `f32`, or 32 bytes a vertex with normals and tangents quantized to bytes and UVs to shorts. Which one a mesh got depends on the mesh. Declare `kind: vertices` and the shader is handed both the buffer and the layout that mesh is in, plus accessors that address it: ```yaml bindings: - { name: verts, kind: vertices } # the mesh, writable - { name: heights, kind: buffer, access: read, element: f32 } params: - { name: lift, type: float, default: 1.0 } ``` ```wgsl @compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id: vec3<u32>) { if (id.x >= zeroVertexCount()) { return; } var p = zeroVertexPosition(id.x); p.y = p.y + heights[id.x] * params.lift; zeroSetVertexPosition(id.x, p); zeroSetVertexNormal(id.x, vec3<f32>(0.0, 1.0, 0.0)); } ``` ```lua carve:dispatchOnVertices({ model = meshRef:handle().guid, buffers = { heights }, workgroups = { 64 } }) ``` The accessors, over a binding declared `read_write`: | read | write | |---|---| | `zeroVertexCount() -> u32` | — | | `zeroVertexPosition(i) -> vec3<f32>` | `zeroSetVertexPosition(i, vec3<f32>)` | | `zeroVertexNormal(i) -> vec3<f32>` | `zeroSetVertexNormal(i, vec3<f32>)` | | `zeroVertexTangent(i) -> vec4<f32>` | `zeroSetVertexTangent(i, vec4<f32>)` | | `zeroVertexUv(i) -> vec2<f32>` | `zeroSetVertexUv(i, vec2<f32>)` | | `zeroVertexColor(i) -> vec4<f32>` | `zeroSetVertexColor(i, vec4<f32>)` | | `zeroVertexNodeIndex(i) -> u32` | — | | `zeroVertexWord(i, offset) -> u32` | — | `access: read` gives you the readers only — a `var<storage, read>` binding cannot be assigned to, so the writers are left out rather than handed to you broken. Once a pass has written a mesh's vertices, the engine bounds that mesh by what the pass wrote: it reduces the vertices to an AABB every frame and culls against that, so geometry a pass moves across the scene needs no oversized placeholder to survive culling. `renderer.mesh.boundsSource(guid)` reports `"compute"` for such a mesh and `"geometry"` for one still bounded by what it was created with. A shader declares at most one `vertices` binding; the accessors are generated for that mesh. It can be declared anywhere in the list — the mesh is bound by the dispatch, and `buffers` fills the other storage slots in declaration order. The tangent's `w` carries handedness, and quantized normals/tangents come back in -1..1 while quantized UVs and colours come back in 0..1, so the values are the same ones a surface shader sees whichever layout the mesh is in. ## shader.wgsl Write only `@compute fn main` (and any helpers / `#include`s). The names declared in `bindings.yaml` are in scope — `data`, `out_tex`, `params.scale`, … — with no `@group`/`@binding` lines of your own. ```wgsl // @domain: compute // Bindings are declared in bindings.yaml — do NOT write @group/@binding here. @compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id: vec3<u32>) { if (id.x < arrayLength(&data)) { data[id.x] = data[id.x] * 2.0; } } ``` ## Dispatch & runtime Everything a compute shader does, it does through its reference: | method | what it does | |---|---| | `ref:dispatch(opts)` | `{ buffers, workgroups, params? }` — bind one buffer per declared storage binding, in order | | `ref:dispatchOnVertices(opts)` | `{ model, buffers?, workgroups, params? }` — bind the mesh `model` names to the `vertices` binding, `buffers` filling the rest | | `ref:dispatchEx(opts)` | `{ resources, workgroups }` — for shaders mixing textures, samplers and storage textures: one `{ kind, name }` per declared binding, in order | | `ref:setParam(prop, value)` | write one `params:` scalar uniform | | `ref:createBuffer(name, opts)` | `{ type, len, usage? }` — a GPU buffer owned by this shader | | `ref:buffer(name)` | the buffer this shader last made under `name` | | `ref:getBindings()` | the parsed `bindings.yaml` schema, read and parsed again only after the file is written | | `ref:getSource()` / `ref:setSource(wgsl)` | read / overwrite `shader.wgsl` | | `ref:compile()` | compile now instead of on the first dispatch | | `ref:status()` | what the engine did with this shader's dispatches, one record per target | The `params:` uniform is engine-owned and takes no entry in `buffers` or `resources`, and neither does a `vertices` binding's layout. `setParam` writes the uniform every later dispatch reads. A dispatch that passes `params = { name = value }` reads those values instead, in a copy of the uniform only it binds, with every name it leaves out at the value the shader holds — so one shader dispatched twice in a frame over different data reads different values each time: ```lua lift:dispatch({ buffers = { a }, workgroups = { 4 }, params = { height = 1.0 } }) lift:dispatch({ buffers = { b }, workgroups = { 4 }, params = { height = 3.0 } }) ``` A name the shader does not declare fails that dispatch, and the failure names what it does declare. ### `dispatchEx` resources Each `resources` entry fills the binding at its position. Its `kind` says what the resource is, in the words `bindings.yaml` uses, and the binding decides how it is read. A slot takes the kind it is declared with; a `texture2d` slot also reads a storage texture, a side of a history pair or the scene depth, and a `storage2d` slot also writes this frame's side of a history pair: | binding's `bindings.yaml` kind | `resources` entry | `name` | |---|---|---| | `buffer` | `{ kind = "buffer", name = buf }` | a buffer handle from `ref:createBuffer` | | `texture3d` | `{ kind = "texture3d", name = vol }` | a 3D texture handle from `ref:createTexture3D` | | `storage3d` | `{ kind = "storage3d", name = vol }` | a 3D texture handle from `ref:createTexture3D` made with `storage = true` | | `storage2d` | `{ kind = "storage2d", name = target }` | a storage texture handle from `ref:createStorageTexture2D` | | `storage2d` | `{ kind = "history_curr", name = history }` | a history pair from `ref:createTextureHistory`: this frame's side | | `texture2d` | `{ kind = "texture2d", name = tex }` | a handle `ref:copyBufferToTexture` returns, or a texture asset's guid once `texRef:handle()` has made it GPU-resident | | `texture2d` | `{ kind = "storage2d", name = target }` | a storage texture handle, read as a texture | | `texture2d` | `{ kind = "history_prev", name = history }` / `{ kind = "history_curr", name = history }` | a history pair: the previous frame's side or this frame's | | `texture2d` | `{ kind = "scene_depth" }` | none: the frame's scene depth | | `sampler` | `{ kind = "sampler", name = smp }` | a sampler handle from `ref:createSampler` | `texture3d` and `storage3d` name the same resource, a 3D texture, so either one fills either slot. The engine's own spellings name the same resources and keep working: `texture_3d` for `texture3d` and `storage3d`, `texture_2d` for `texture2d`, and `storage_2d` for `storage2d`. Any other `kind` is refused at the call, naming every kind it accepts. A `vertices` binding is filled by `ref:dispatchOnVertices`, and `texture_depth`, `textureCubeArray` and `acceleration_structure` bindings by a render feature's pass. ```lua local march = asset.resolve("volume_march", "computeShader") -- bindings.yaml: density (storage3d), out_tex (storage2d), U (buffer) march:dispatchEx({ resources = { { kind = "storage3d", name = density }, { kind = "storage2d", name = target }, { kind = "buffer", name = uniforms }, }, workgroups = { 8, 8, 8 }, }) ``` `workgroups` is `{ x, y, z }`, `{ x }`, or a bare `x`, and every dimension it leaves out is 1. A zero in any dimension is refused: the dispatch is dropped and recorded as a failure, so it surfaces in `ref:status()` and `compute.failing()` with the reason. A count derived from how much data there is passes through `math.max` first, so an empty set dispatches one workgroup rather than none: ```lua carve:dispatch({ buffers = { heights }, workgroups = { math.max(1, math.ceil(#points / 64)) } }) ``` ### Buffers and readback (the CPU side) A buffer is created through the shader that owns it and comes back as a handle. Pass the handle to a dispatch — including another shader's — to bind it there: ```lua local double = asset.resolve("@builtin::shaders.compute_double", "computeShader") -- `type` is the element the shader declares, `len` how many of them. local data = double:createBuffer("data", { type = "f32", len = 4, usage = { "readback" } }) data:write({ 1.0, 2.0, 3.0, 4.0 }) -- :writeU32 for integer words, :writeBytes for packed bytes double:dispatch({ buffers = { data }, workgroups = { 1, 1, 1 } }) local pending = data:read() -- starts an async GPU->CPU readback -- a frame or two later, drain it (nil until it has arrived): local out = pending:result() -- :resultU32 / :resultBytes read the same bytes another way data:destroy() -- free it when done ``` `type` is one of `"f32"`, `"vec3"`, `"vec4"`, `"quat"`, `"mat4"`, and `usage` adds `"readback"`, `"vertex"`, `"index"` or `"indirect"` on top of the storage a buffer always has. An `"indirect"` buffer holds a draw's arguments where the GPU reads them, so a shader that compacts geometry writes the drawn count into the same dispatch that produced it and a render feature's `kind = "draw"` pass submits exactly that — the `renderFeature` asset type's README documents that pass and the `storage.indirect` name it reads. The handle's key is the shader's guid plus the name you gave it and a serial, so two shaders that both want a buffer called `data` get one each — and so do two callers asking this same shader for one. `ref:buffer(name)` returns the last one made under that name, which is how two callers share one on purpose. A buffer that no shader computes — a vertex or index run that is only ever drawn — is allocated straight from the primitive instead: `substrate.createBuffer({ name = ..., type = ..., len = ..., kind = "gpu", usage = { "vertex" } })`. The first dispatch of a shader compiles it, so the very first readback can still reflect the un-run shader; dispatch again once it is compiled, or `ref:compile()` to pre-warm. This create/write/dispatch/readback loop is the CPU side of the resource model in `core/resource-model`. ### Is it compiled? `ref:compile()` queues the compile; the pipeline is built on the render side frames later, so that call cannot say whether it produced a program. `compute.programState(ref)` is where that lands: ```lua if compute.programState(carve) == "ready" then carve:dispatch(opts) end local state, reason = compute.programState(carve) if state == "failed" then print(reason) end ``` - `"absent"` — the engine holds nothing under this key and nothing is in flight: never asked for, or released. - `"pending"` — a compile was asked for and has not reached the device. A recompile of a resident program reads pending too, because what it produces is a different program from the one bound now — which is what makes this the precondition to wait on after an edit. - `"ready"` — compiled and resident, so a dispatch naming it binds it. - `"failed"` — the most recent compile produced no program, and the second return says why. A dispatch issued while the program is `"pending"` is held behind the compile and dropped if it never lands, so a readback taken before `"ready"` reports the buffer as it was. Wait for `"ready"`, not for a count of frames. ### Did it run? A dispatch is recorded into a command encoder frames after the call that asked for it returned, so that call's `true` means "queued", not "ran". `ref:status()` is where the outcome lands — one record per target, cumulative: ```lua for _, d in ipairs(carve:status()) do print(d.target, d.ok, d.dispatches, d.failures, d.lastError) end ``` `target` is the mesh guid for a `dispatchOnVertices` and the buffers it bound for a dispatch that writes only those, so two systems driving one shader over their own buffers read as the two passes they are. `ok` is the most recent outcome; `lastError` says why the last failure failed and is kept after a recovery. An empty result means nothing has dispatched this shader at all. `compute.failing()` answers the same question across every shader at once. A failing dispatch also posts a notice, so a pass that stops running reports itself rather than waiting to be found. ### Running one every frame A `kind = "compute"` substrate job queues one dispatch per frame: ```lua jobs.register({ phase = "main", executor = { kind = "compute", shader = asset.resolve("carve", "computeShader"), buffers = { "heights" }, workgroups = { 64 }, }, }) ``` ## Registration & hot reload Writing `shader.wgsl` or `bindings.yaml` fires the `.computeShader` assetType's `onChange`, which calls `__compute.compile` — that generates the binding declarations, expands `#include`s, naga-validates, and registers. This happens on create and on every edit, so saving recompiles with no world reload. A shader with invalid WGSL (a type mismatch, an undefined name) **fails to compile** with the reason logged (`[compute] Compile of '<name>' failed: …`) and is **not** registered. You don't need to register a shader before dispatching it — the first dispatch auto-compiles it on use. `ref:compile()` remains as an optional explicit pre-warm, only to avoid the one-frame first-dispatch warm-up in a latency-critical spot. ## What compute is holding, and why a key names nothing Every buffer, volume, storage target, history pair and sampler a dispatch can bind is filed under a key, and `compute.observe()` lists all of them with what each costs: ```lua local r = compute.observe() print(r.totals.count, r.totals.bytes) for _, res in ipairs(r.resources) do print(res.key, res.kind, res.bytes, res.owner.name) end ``` A row carries the key verbatim — the same string a dispatch names it by and a dispatch-failure message prints — plus its `kind`, its `bytes`, its extent and `format` where it has them, the `usage` bits it was created with, and the frame it was created on. `owner` is read back out of the key (`<shader-guid>::<name>#<serial>`), so a resource nothing has ever dispatched over still names the shader that made it. `compute.resources(ref)` narrows to one shader's — including the `<shader-guid>::__params` uniform the compile created for a `params:` block, which the shader owns like any other resource. `r.totals.bytes` is the `compute` figure of `renderer.gpuMemory()` — the two are read off the same registries, so a listing accounts for the category exactly. The same reading is served at `/zero/runtime/compute`. When a key names nothing, `compute.diagnose(key)` says which of the states the inventory distinguishes it is in, from the closed set `compute.absentReasons()` enumerates: | reason | what the inventory read | |---|---| | `resident` | a resource is filed under exactly this key; the row is on `resource` | | `replaced` | the owner holds the same name at a different serial — `current` names the live key | | `nameUnused` | the owner holds resources, none under this name | | `ownerAbsent` | no live resource carries this key's owner | | `unowned` | the key carries no owner segment, and nothing is filed under it | | `notPublished` | no renderer has published an inventory, so no key can be answered for | So a key out of `[compute] dispatch '…' failed` resolves against what exists, and a typo reports which part of it the engine could not match rather than coming back empty. A reason is the inventory at the moment the call read it. The reading is republished on a frame where a registry gained or lost an entry, so a resource created and diagnosed inside one script sees the reading from before it was made; wait a frame and ask again. ## Discovery - `asset.list("computeShader")` — every registered compute shader. - `asset.inspect("<name>")` — status, source, parse errors, this README. - `computeRef:getBindings()` — the parsed `bindings.yaml` schema (`{ bindings, params }`). - `compute.programState(ref)` — whether the program is compiled, still compiling, or failed, and why. - `compute.observe()` — every GPU resource compute holds, and what it costs. - `compute.diagnose(key)` — whether a key names a live resource, and why not. - `profiler.gpuFrame()` — what each dispatch of this shader spends on the GPU. A dispatch is timed under `compute.<identity>` — the same identity `asset.resolve` takes, so a timing row names the shader that produced it. ## Common pitfalls - **Compute shaders don't render** — verify output via a buffer readback or a debug pass, not a screenshot. - **`ref:compile()` returns before the program exists** — the pipeline is built on the render side; `compute.programState(ref)` says when it is `"ready"`, and a dispatch issued before that is dropped. - **A dispatch call returning true means queued** — ask `ref:status()` whether it ran. - **A destroyed buffer is refused where it is bound** — a handle outlives its storage, and `buf:alive()` reports which. - **Binding order is the contract** — `buffers` and `resources` bind in `bindings:` list order; reordering the list reorders the bindings. - **Don't write `@group`/`@binding` in `shader.wgsl`** — the engine owns them; re-declaring is the fastest way to a layout mismatch. - **Don't infer a mesh's vertex stride** — declare `kind: vertices` and use the accessors. A wrong stride writes positions correctly and normals into the wrong word, which renders as flat lighting rather than as broken geometry. - **Storage-texture format must be storage-compatible** — `r32f` / `rgba8` / `rgba16f` / `rgba32f`; others are rejected at compile. - **A key that comes back empty is not the same as one that failed** — `compute.diagnose(key)` separates a name never used from a generation that was replaced, and names the live key in the second case. ## Related types - `.shader` — render-domain shaders (surface, sky, post-process, screen) and the legacy `@domain: compute` fallback. ## In the Inspector Selecting a compute shader opens its **Compute** section: whether its program is ready (and why not), how many dispatches it has run and failed in this session (and the last error), tables of the bindings and params its `bindings.yaml` declares, and openers for `shader.wgsl` and `bindings.yaml`.
▲ 0↑ born
·
assettype · born here
❒asset
# texture An **image asset** — the raw bytes of a `png` / `jpg` / `jpeg` / `hdr` / `ktx2` / `webp` / `bmp` / `tga` file. Loose image files under a `textures/` directory classify as `texture`. > New to how a texture exists as an asset, as CPU-side pixels, and as a live GPU > texture (and how `renderer.texture.*` and render targets move between them)? > Read `core/resource-model` first. `texture` is a first-class assetType (not a bare plural-dir category) so that: - a texture has a stable asset **identity** and an explicit `asset_type` link, and - a material that references a texture records a real `.refs` edge to it — which is what lets the CPU-residency system cascade from a material to the textures it actually uses. ## CPU residency vs GPU residency A texture can be resident two fundamentally different ways, and they must not be conflated: - **CPU residency** (this assetType): the raw image **bytes in memory** — readable, hashable, decodable. You can operate on the bytes. - **GPU residency**: those bytes uploaded to the device as a `wgpu::Texture` the renderer binds/samples. Byte-level reads are **not possible** on the GPU variant — that is simply how the device works. GPU residency is managed separately (`TextureRefCounts` → `drop_texture`) and is **not** part of this assetType. Same texture identity, two separate residency states, two separate operation sets, two separate reference counts. See `docs/specs/runtime-asset-copying.md`. ## Internal layout A `.texture/` is permissive (`allow_unlisted: true`). `asset.create("texture", name, ...)` and the **texture importer** (which creates through it when a loose raster image lands in `/source/`) produce this shape: ``` <name>.texture/ source.<ext> the retained source raster — see below source.ztex the full-resolution texels a cook dropped some of — see below settings.yaml the texture's settings — see below data.ztex canonical pre-encoded + pre-mipped payload (ZTEX magic) .metadata what data.ztex holds and was encoded from ``` `asset.create` keeps a copy of the image bytes it was given as `source.<ext>`; the importer moves the uploaded image in under its own name instead (`foo.png` stays `foo.png`). Either way it is the texture's retained source: rewriting it re-encodes `data.ztex` from the new pixels, and a rewrite carrying the bytes `data.ztex` was already encoded from changes nothing. `data.ztex` is the **preferred primary**: when present the renderer resolves and uploads it verbatim — no re-mip. When absent the primary falls through to a raster image in the folder, so a container holding only an image file reads that image. (`data.tex` is the pre-rename legacy payload name — still resolved for existing assets; new content writes `data.ztex`.) That fallthrough takes the lowest-named raster image in the folder and never the generated `preview.png` thumbnail, which is rendered from the payload. A container that lost its encoded payload and holds no other image has no primary: `asset.diagnose(ref)` reports `noPrimaryFile`, `asset.validate(ref)` reports `texture.no_payload`, and `renderer.texture.loadCpu` refuses it. `asset.primaryFile(ref).path` names the file the resolution landed on; the asset-system guide covers `diagnose`. #### `settings.yaml` settings A texture's settings are declared in `settings.luau` and live in its `settings.yaml`. `asset.create` stamps them when the texture is made, and `texRef:setSettings({ ... })` (or `asset.setSettings(ref, patch)`) changes them. Editing an encode-relevant one re-runs the encode against the retained source and rewrites `data.ztex`. A texture whose settings were stored under an earlier schema reads with the same meaning, and its first local edit rewrites them into `settings.yaml` under the fields below. ```lua local tex = asset.create("texture", "hero_albedo", { bytes = png, ext = "png", settings = { maxDimension = 1024 }, }) tex:setSettings({ uastcRdo = "medium" }) ``` | key | values | default | meaning | |-----|--------|---------|---------| | `textureType` | `color` / `normalMap` / `mask` / `sprite` | `color`, or guessed from the name | what the texture holds, recorded in the payload. `normalMap`: tangent-space normals — when `generateMipmaps` builds the chain, every level of it, the base included, is renormalised to unit length; with `generateMipmaps = false` the one stored level is the source as given, and the chain the GPU builds from it at upload is a plain box filter. `mask`: linear data (roughness, metallic, occlusion, height, masks). `sprite`: a 2D image with transparency. A new texture's type is guessed from its name — `_normal` / `_nrm` / `_n` / … → `normalMap`, `_rough` / `_metal` / `_ao` / `_height` / `_mask` / … → `mask` — unless it is given. | | `colorSpace` | `srgb` / `linear` | follows `textureType`: `srgb` for `color` and `sprite`, `linear` for `normalMap` and `mask` | how the stored values are read when sampled. `srgb` for albedo/emissive/base-color, UI and sprites; `linear` for normal/RM/AO/height/mask data. | | `compression` | `uastc` / `rgba8` / `rgba16` / `rgba32f` | a new texture: `uastc`, a `sprite` `rgba8` | what the texels are stored as — see "What each compression stores" below. | | `effort` | `fastest` / `fast` / `thorough` | `fastest` | how long a `uastc` encode searches for each block — see "Block compression" below. | | `uastcRdo` | `none` / `low` / `medium` / `high` | `none` | how far a `uastc` encode moves from the source to make a payload that compresses smaller on disk and over the network — see "Block compression" below. | | `generateMipmaps` | `true` / `false` | `true` | bake the full mip chain into `data.ztex` at encode time. | | `maxDimension` | integer, `1` to `8192` | a new texture: `4096` | the longest side the encoded texture may have; a larger source is downsampled to fit, and a `uastc` texture's longest side is the largest power of two within it. A value past 8192 is refused. | | `filter` | `linear` / `nearest` | `linear` | sampler filtering recorded in the payload. A material slot and a UI image widget sample the texture with it. A post-process effect's texture property, a render pass's `@texture` input and a compute shader's `texture_2d` binding sample through a sampler of their own (the effect's `postprocess.setSampler`, the pass's, the compute shader's named `sampler` binding), which this setting does not change. | | `wrapU` | `repeat` / `clamp` / `mirror` | follows `textureType`: `clamp` for `sprite`, else `repeat` | how a material samples the texture past its left and right edges: tiled, the edge texel held, or mirrored. Recorded in the payload. | | `wrapV` | `repeat` / `clamp` / `mirror` | follows `textureType`: `clamp` for `sprite`, else `repeat` | the same, past its top and bottom edges. | | `anisotropy` | `world` / `1` / `2` / `4` / `8` / `16` | `world` | samples taken along a surface seen at a grazing angle. `world` follows the `render.anisotropy` world setting (`renderer.setAnisotropy`); a number fixes it for this texture, clamped to what the device supports. A `nearest` texture samples with 1. A write may give the number itself (`anisotropy = 8`); it is stored as the string. Recorded in the payload. | | `alpha` | `input` / `premultiplied` / `none` | `input` | what the encode does to alpha. `input`: kept as the source has it (straight alpha). `premultiplied`: colour multiplied by alpha — decoded to linear light, multiplied and re-encoded for an `srgb` texture, multiplied as stored for a `linear` one — the source a material with the `premultiplied` blend mode (`src + dst * (1 - src.a)`) composites. `none`: every texel opaque, colour kept. | | `alphaIsTransparency` | `true` / `false` | follows `textureType`: `true` for `sprite`, else `false` | fill every fully transparent texel with the average colour of its non-transparent neighbours, spreading outward, so bilinear filtering and mip averaging never pull black into the edge of a transparent area. Alpha stays 0. Runs before `alpha` and before the mips. | | `keepCpu` | `true` / `false` | `false` | keep the decoded pixels in the CPU store after the GPU upload — see below. | The type's colour space, `alphaIsTransparency`, `wrapU` and `wrapV` are stamped when the texture is created, and a later write that changes `textureType` moves them with it — unless the same write names them. A texture whose settings predate `textureType` reads as `color`, with `alpha = input` and no transparency fill, which is what its payload holds. A cook that drops texels — a `maxDimension` smaller than the source, or a `uastc` cook resampling a side to a power of two — keeps the full-resolution input as `source.ztex` (one RGBA8 level) when the container holds no source image, so raising `maxDimension` later re-encodes from every texel. A cook that drops nothing keeps nothing extra. A texture made from raw pixels whose cook dropped nothing keeps its payload as its only source, so a settings change re-encodes from that payload read back with straight alpha: what an earlier encode discarded stays discarded, after `alpha = none` its alpha is opaque, and a `uastc` payload re-encoded is the blocks' texels, not the pixels first given. A new texture's settings start from the texture type's own defaults — `uastc` with a `maxDimension` of 4096; `rgba8` for a `sprite`; a 16-bit PNG or `.hdr` source left to the schema's `rgba8` unless a layer names a compression — and a world's Preset Manager rows for `texture` override them (a world that ships at 2048, say), then an importer's settings, then the `settings` the create names. `filter`, `wrapU`, `wrapV` and `anisotropy` say how the texture is sampled, not what its texels are. A write that changes only those rewrites the payload's header (`renderer.texture.withSampler`): every level keeps its bytes, and no preview is rendered, because no pixel changed. Every material slot samples through its own texture's state, so two textures on one material can wrap and filter differently. UI image widgets honour `filter` and draw each image over its own bounds, so the wraps apply to materials. A post-process effect, a render pass `@texture` input and a compute shader sample through samplers of their own, so none of the four settings reaches them. A texture whose payload predates wrap and anisotropy reads `repeat` / `repeat` / `world`, which is how it has always been sampled. `keepCpu` is a lifecycle setting, not an encode input — it never re-runs the encode. It acts on an already-materialised texture right away instead: `true` loads the decoded pixels into the CPU store when the texture is GPU-resident, `false` drops a resident CPU copy. The `data.ztex` payload format is `zero_texture::blob` (`ZTEX` magic + 32-byte header + mip levels); the CPU encoder is exposed to Luau as `renderer.texture.*` and consumed by the texture importer and this assetType. #### What each compression stores | `compression` | In `data.ztex` | On the GPU | Limits | |---|---|---|---| | `uastc` | UASTC 4x4 blocks, whole mip chain, one byte per texel, each level compressed for storage. | Transcoded at upload to a block format the adapter samples: `Bc7RgbaUnorm[Srgb]` where it samples BC textures (desktop native, desktop WebGPU), ASTC 4x4 where it samples ASTC and not BC (phones, a browser on one), ETC2 RGBA8 where it samples only ETC2, each one byte per texel; RGBA8 at four bytes per texel on an adapter that samples none of them, and for a texture with a side under four texels. | each side is resampled to the nearest power of two (halfway rounding up), nearest-neighbour for a `nearest` texture. | | `rgba8` | From an image with `generateMipmaps = false`, a `maxDimension` the image fits within, `alpha = input` and `alphaIsTransparency = false`: the source PNG / JPEG / WebP itself, decoded at load. Otherwise raw 8-bit RGBA with the encoder's box-filtered mip chain. | `Rgba8Unorm` / `Rgba8UnormSrgb`, four bytes per texel. | none | | `rgba16` | Raw 16-bit unsigned normalized, one level. An image is decoded at its own depth: a 16-bit PNG keeps every bit, an 8-bit channel `v` is stored as `v * 257`. The frames of an animated image are decoded at 8 bits. | `Rgba16Unorm` where the adapter grants 16-bit normalized textures (native); half float (`Rgba16Float`, about 11 significant bits) where it does not (every browser). | one level, no `maxDimension`, `linear` only. | | `rgba32f` | Raw 32-bit float, one level. An image channel is stored as its value over the channel's maximum. | `Rgba32Float`. Where the GPU filters 32-bit float, every consumer samples it. Where it does not (every browser): a material slot draws the shader's declared default; a UI image draws nothing; a render pass naming it as an `@texture` input is skipped; a compute shader that declares no sampler binds it and reads it with `textureLoad`, and one that declares a sampler refuses the dispatch — each logging once, naming the texture and its format. CPU reads (`:load()`, `renderer.texture.decode`) return every bit on every device. | one level, no `maxDimension`, `linear` only. | A high-precision compression turns `generateMipmaps` off, `maxDimension` to 8192 and the colour space to `linear` when the same write does not name them, and refuses a write that names a contradicting value. A payload of BC7 blocks, written before `uastc` existed, stays as it is: `Bc7RgbaUnorm[Srgb]` where the adapter samples BC textures, re-encoded as it loads into ASTC 4x4 or ETC2 where the adapter samples those instead, and decoded to RGBA8 where it samples none. Its settings read `compression = uastc`, what an encode of it now asks for, while `texRef:info()` reports the `bc7` payload it holds. #### Block compression `compression = "uastc"` stores every mip level as UASTC blocks — sixteen bytes per 4x4 texels — a format no GPU samples directly but every one can be handed: at upload each level is transcoded to a block format the adapter samples, BC7 on a desktop GPU and ASTC 4x4 on a phone's (ETC2 on one that samples ETC2 alone), so one payload is resident at one byte per texel on each, and RGBA8 on an adapter that samples none; `renderer.textureMemory().blockFormat` names the format this adapter holds it in. Only the levels a device keeps are transcoded: under a texture-size cap (`renderer.setMaxTextureSize`) a texture is uploaded from the first level of its chain within the cap. Compression is lossy, which makes it the choice for colour, normal and detail maps; `rgba8` holds a texture exactly, which is what pixel art and a mask whose exact codes matter need. `effort` is how long the encode searches for each block. `fastest` tries one candidate for an opaque block, `fast` takes about 6 times as long and `thorough` about 17 times, each landing closer to the source texels. What the GPU holds is the same size at every effort, and so is the time a load takes. `uastcRdo` trades accuracy for a payload that compresses smaller: `none` encodes each block as close to the source as the encoder finds, and `low`, `medium` and `high` let a block move further from the source when a nearby block's bytes would let the payload compress better. What the GPU holds is the same size either way; what moves is the size of `data.ztex` on disk and over the network, and the encode time, which a pass more than doubles. The encode runs on the CPU when the texture is created or a setting that decides its texels changes. A call from a task that can yield cooks off the frame and the task waits for it, so the world keeps running: a 1024x1024 texture with its mip chain takes about a second on a desktop CPU. A texture whose settings predate `compression` (stored before it was a setting) is healed by its first local edit: a `nearest`-filtered one becomes a `sprite` in `rgba8`, an `rgba16` or `rgba32f` one keeps its format, and every other one becomes `uastc` and is re-encoded; an older "no cap" becomes a `maxDimension` of 8192. Loading one changes nothing, and an edit arriving from another session or a texture installed from a library is never healed. A texture whose settings say `rgba8` is never compressed. ## Reading an asset from its ref `texRef:settings()` reads the container's settings with every schema default filled in and `texRef:setSettings({ maxDimension = 1024 })` patches them; `texRef:load()` decodes the payload's pixels into the CPU store for per-pixel access, `texRef:handle()` puts the texture on the GPU under the asset's own guid, and `texRef:preview()` renders it as a swatch. `texRef:info()` reads the compiled `data.ztex` payload's header: the format it was compiled to — also as its `compression` and `colorSpace` — the `textureType`, `alpha` and `alphaIsTransparency` it was encoded under, its width and height, its mip and array-layer counts, the sampler recorded in it (`filter`, `wrapU`, `wrapV`, `anisotropy`), and, for an animated sequence, each layer's display time and the length of one pass: ```lua local info = texRef:info() print(info.width, info.height, info.format, info.mipCount) ``` It answers off that payload's own header, so a compiled texture reports how big it is for a header read where `load()` decodes its pixels and `handle()` uploads them. A container whose primary is a source image answers `(nil, err)`, and `load()` reads its size. The `compression` and `colorSpace` it reports are what the payload holds; the settings table holds what the encode was asked for. ## In the Inspector Selecting a texture opens its **Texture** section right below the asset's identity, then its Settings. The section draws the encoded texture through the texture's own sampler, so a `filter = "nearest"` texture reads crisp at every zoom. The zoom row offers **Fit** and each integer zoom (1×, 2×, 4×, 8×) whose drawn size fits 512 px; a 128 px texture offers up to 4×, and one wider than 512 px offers Fit alone. Below the view, the section lists every field of the `data.ztex` header (`texRef:info()`) — size, format, mip levels, filter, layers, and a sequence's frames and duration — and the GPU memory the encoded mip chain takes across its layers. The filter and the other encode settings change in the Settings section; the Texture section reports the re-encoded result.
▲ 0↑ born
·
assettype · born here
❒asset
# guide A guide is a single-file documentation asset: a `<name>.guide/` folder whose body is `guide.md`, surfaced through the `guides` MCP tool, the `man` shell builtin and the Docs panel. Drop one anywhere under `/zero/source/`, a `.package` included, and it shows beside the built-in guides. ## In the Inspector Guide: the guide's word count, its headings, and **Open in Docs**, which opens it in the Docs panel.
▲ 0↑ born
·
assettype · born here
❒asset
# Material (asset type) A material is a named, configured instance of a `.shader`. The shader defines the surface look (lighting model, available property fields, texture slots); the material picks values for those fields and a shader to bind to. Renderable entities reference materials, not shaders, so the same geometry can wear different materials and the same material can be reused across many entities. Entities sharing one material can still differ in a value: every drawn object carries four `vec4` lanes of its own that a surface shader reads as `input.shader_data[lane]` and `renderer.instanceData.set` writes. Use that for a dissolve at its own progress per subject, a hit flash at its own age, a fill level per instance — a material per entity is for when the LOOK differs, not when a number does. > New to how a material relates to its shader, its textures, and the live GPU > material behind it? Read `core/resource-model` first. It also covers the > difference between an authored `.material` asset (this type) and a runtime GPU > material made with `renderer.material.create`. ## When to use one - You want a named surface configuration an entity wears by referencing it from its `Model` component (`material = "@.../<Name>"`). - You want runtime property tweaks (`asset.resolve(ref):setProperty(...)`) to affect every entity using the material at once. - You want to publish a curated look (`Gold`, `RedGlow`, `BrickWall`) other authors can reuse without re-typing shader bindings. If you need a brand-new lighting model or a custom visual effect, you need a `.shader` first; the material wraps it. ## Where it lives - Source: `/zero/source/.../<Name>.material/` - Identity: `<Name>` (the `.material` suffix strips). - Folder shape: - `mat.yaml` — canonical material spec (shader + property values). **Required.** - `preview.png` — the material's rendered shader-ball still, its persisted visual description. **Required.** Rendered when the material is staged (`zm add`) with its preview missing or stale, and by `generatePreviews`; it syncs, publishes, feeds image-based search, and gives browsers a thumbnail. - `README.md` / `.metadata` — optional prose + tags, indexed when an author writes real ones. - `textures/` — optional folder for embedded textures. Validator allows the folder; its interior is unrestricted. - `shaders/` — optional folder for a co-located custom shader. Same rule. ## How to create one ```luau asset.create("material", "<Name>") -- Creates: /zero/source/<Name>.material/ -- mat.yaml (shader binding + property scaffold) -- preview.png (rendered when the material is staged with `zm add`; the -- picture lands as a change the next `zm add` stages) -- `folder` places it in a subfolder of /source instead of the root: asset.create("material", "<Name>", { folder = "materials" }) ``` Pass the shader and initial values through `asset.create`, then tune with `setProperty` and keep what you tuned with `saveDefinition`: ```luau local mat = asset.create("material", "<Name>", { shader = "@builtin::shaders.pbr" }) mat:setProperty("base_color", { 0.8, 0.6, 0.1, 1 }) mat:setProperty("roughness", 0.3) mat:saveDefinition() -- writes the tuned values into mat.yaml ``` This mints the `.material` asset and returns a handle you tune with `setProperty`. A set is a runtime change: it reaches the GPU material and every entity wearing it, and `saveDefinition` is the call that writes it into `mat.yaml`, so a property driven every frame costs no file write and leaves the authored file as its author wrote it. A purely runtime `asset.create` lives only for the session. For an ephemeral, runtime-only GPU material that is not an asset at all, use `renderer.material.create` instead (see `core/resource-model`). ## How it operates 1. **Property reflection.** The bound shader's uniform struct fields become the material's configurable properties — no manual property declarations needed. `naga` introspects the shader at load. 2. **Property values.** `mat.yaml` groups values by type, each block keyed by the shader's field names: `properties:` holds scalars and colors/vectors, `ints:` holds integer-typed fields, `bools:` holds boolean-typed fields, and `textures:` holds texture slots. Omitted fields take the shader's default. A scalar is a bare number (`roughness: 0.5`); a color or vector is an array (`base_color: [1, 1, 1, 1]`). `shader:` is the shader's identity string. This is the shape every material the engine writes uses. A `saveDefinition` writes the changed values into the file where they already stand and leaves the rest of it — its comments, the order of its keys and blocks, the spelling of its literals — untouched. The older `floats:` / `colors:` blocks (with `{ r, g, b, a }` map colors) still load for materials hand-authored that way. 3. **Render state.** `render:` holds how the mesh is drawn rather than what the shader reads: `blend` (the equation the fragment composites with, also spelled `type`), `cull`, `depth_write`, `depth_compare`, `topology` and `queue` (the draw order, a whole number: `2000` opaque, `3000` transparent, `4000` overlay). The `.shader` type README's "The render block" section lists every value each key takes, and `asset.create`'s `render` argument takes the same keys and values. 4. **Application.** An entity wears a material by referencing it from its `Model` component (`material = "@.../<Name>"`). Materials affect every entity that references them; runtime `asset.resolve(ref):setProperty(...)` changes propagate to all of them immediately, and last until the next mode change unless `saveDefinition` writes them into `mat.yaml`. 5. **Texture binding.** Texture slots accept `color:r,g,b,a` for solid colors, `default:white` / `default:black` / `default:normal` for the engine's own fallback texels, `@builtin::textures.<name>` for builtins, `path/to/image.png` for file textures, or a render-target handle for camera renders. The GPU texture cache reads the `color:` and `default:` forms from the string itself, so a slot written in one names no asset and carries nothing with the material when it travels; every other form names a texture asset the slot depends on. A texture in `base_color_texture` is multiplied by `base_color`: every built-in surface shader declaring both draws the texture's colour times `base_color`, channel by channel. Under the default white `base_color` the texture shows as itself, and a coloured `base_color` tints it. The two read a colour differently: `base_color` is linear, and the slot reads its texels as sRGB-encoded, a `color:` value's included, the way it reads an image. A colour stated in both places is therefore multiplied by a darker copy of itself: `color:0.62,0.14,0.05,1` in the slot decodes to `0.34, 0.017, 0.0039`, so beside `base_color` `0.62, 0.14, 0.05` the surface draws `0.21, 0.0024, 0.0002`, far darker than either. State a solid colour once, as `base_color`, and leave the slot at its default white. 6. **Hot reload.** Editing `mat.yaml` repacks the GPU uniform buffer on the next frame; visible immediately. 7. **Inspector.** `type.yaml` names `inspector: { type: "inspector.luau" }`, and `inspector.luau` declares the material's sections beside the generic ones: `shader` (the bound shader, swappable), `properties` (each shader field as its typed control) and `textures` (each texture slot as an asset chip). They read the material through `mat:getShaderRef()` (the bound shader's ref, `~` expanded) and `mat:getTextures()` (slot name → `{ ref, name }`: the bound texture's guid and the id the material names it by). An edit there is an authoring write: `mat:writeProperty(name, value)` and `mat:writeTexture(slot, texture)` write the value into `mat.yaml` (and drop the session's own value for it, so the material reads what the file holds), and `setShader` rewrites the file; inside an undo operation each file write is recorded, so undo puts `mat.yaml` back. `setProperty` / `setTexture` stay runtime writes that last until the next mode change. ## Discovery - `asset.list("material")` — every registered material. - `asset.inspect("<name>")` — shader binding, property values, source path, this type README. - `ls /zero/source/<Name>.material` lists the files the folder holds. - `asset.resolve("<name>"):getProperties()` — the property fields exposed by the bound shader, each at the value it currently holds: what `mat.yaml` authored until something writes to the material, and the written value from then on, whichever call wrote it. That is the value the surface is drawn with. Read them back rather than guessing names; which fields exist comes from the shader. - `asset.resolve("<name>"):getDefinition()` — the authored `mat.yaml` bytes, the value the material rests at. - `asset.resolve("<name>"):getShader()` — the shader the material binds. ## Authoring conventions - Use lowercase, descriptive names (`brushed_steel`, `red_glow`, `tarmac_wet`). Avoid shader-name suffixes in the material name — the binding lives in `mat.yaml::shader`. - Drive every property the shader exposes; defaults are a fallback, not a contract for which fields exist. - Embed textures alongside the material (`textures/` subfolder) when they're used only by this material; reference shared textures from `@builtin::textures.*` or a library. ## Common pitfalls - **Property names must match the shader's uniform struct exactly** (case-sensitive). Misnamed properties silently get the shader's default. - **`base_color_texture` multiplies `base_color`.** A `color:` texture holding the same value as `base_color` multiplies the colour by its own sRGB-decoded value, so the surface renders far darker than the value written. Write a solid colour as `base_color` alone. - **Broken shaders break the material.** A material whose shader has a parse error renders as the magenta checkerboard. Inspect the shader first if the material looks wrong. - **`setProperty` targets the material, not the entity.** Changes affect every entity that references the material. - **`setProperty` is a runtime change.** It reaches the GPU, not the file: call `saveDefinition` to write the current values into `mat.yaml`. For a per-entity override that no other entity sees, give the entity its own copy with `Model:applySessionMaterial(renderer.material.create(...))`. - **Renaming the folder changes the identity.** Update every `Model` component and `asset.create` callsite that references it. ## Related types - `.shader` — defines what properties exist; required before a material can bind to it. - `.preset` — captures a component configuration; conceptually similar to a material, but for components instead of shaders.
▲ 0↑ born
·
assettype · born here
❒asset
# Module (asset type) A module is a reusable, stateless (or globally-stateful) Luau library that other code pulls in with `require()`. Modules are the unit of factored-out logic in a world: helpers, math libraries, format parsers, registries, shared algorithms. ## When to use one - You have logic you want to share across multiple components, services, scenes, or tools. - You have a registry, lookup table, or shared cache that callers all read from / write to. - You want a stable API surface (`M.fn`) other code can depend on. If the logic is bound to one entity, use a `.component`. If you want a named UI of fields rather than functions, use a `.preset`. ## Where it lives - Source: `/zero/source/.../<name>.module/` - Identity: the source path with `/` written as `.` and each segment's type suffix dropped — `/zero/source/demo/beats.module` is `demo.beats`, and `/zero/source/kit.package/beats.module` is `kit.beats`, the container's name kept and its `.package` suffix gone. `asset.create` returns this string, `asset.resolve(...).identity` reads it back, and the `require` forms below write it as `<identity>`. - Folder shape: - `init.luau` (or `init.lua`) — module body. **Required.** Returns a table (conventionally named `M`). - `README.md` — instance-level documentation. **Required.** - `.metadata` — agent-editable tags + free-form fields. **Required.** ## How to create one ```luau asset.create("module", "<name>") -- Creates: /zero/source/<name>.module/ -- init.luau (canonical `local M = {} … return M`) -- README.md (instance README template) -- `folder` places it in a subfolder of /source, and that subfolder -- leads the identity: `{ folder = "lib" }` produces `lib.<name>`. -- `into` authors it inside a resolved container (a package, a toolbox), -- whose own identity leads the same way: asset.create("module", "<name>", { folder = "lib" }) asset.create("module", "<name>", { into = asset.resolve("myPkg.package") }) ``` ## How it operates 1. **Registration.** Writing `init.luau` into a `.module/` folder indexes the asset and registers the module path with the resolver. 2. **Loading.** The first `require("<identity>")` runs the file's body and caches the returned table. Subsequent requires from anywhere return the **same table** — this is the shared-state lever for "module-local" registries. The body runs in an environment of the module's own: a name it declares without `local` is readable by the module's own functions and by no other script, so an export reaches its callers through the returned table and nothing else. Requiring a component's folder runs its chunk the same way, once, and its `update`, `start` and `public` stay in that environment; a component runs on an entity through `entity(id).component.add(name)`. 3. **Hot reload.** Editing the file re-runs the module body and folds what it returns into the table already loaded, so that table keeps its identity: a caller that captured it with `require` reads the new source's functions and values through the reference it already holds, and a later `require` returns that same table. A key the new source drops goes away; a key something set at runtime stays. The body's own `local`s start again from their initial values, so state that must outlive an edit lives in `modules.state()` (`guides { path: "core/scripting-and-tasks" }`). 4. **Identity resolution.** A module is reached by its `<identity>`, and where the caller lives decides which spelling of it resolves. For a module in a **world**, from a caller in that same world — another module, a component, a scene entrypoint, or an `execute` chunk: - `require("<identity>")` — the identity on its own, resolved against the caller's own root. It carries every folder segment, so a module created with `{ folder = "demo" }` is `require("demo.beats")`. - `require("@root::<identity>")` — the same root-relative resolution, written out. - `require(".sibling")` — a module in the same folder, by its bare name; `..name` steps up one folder, `...name` two. Logs, stack traces and hot-reload notices name that same module `@local.source.<identity>` — its key under the world source root. The `<identity>` tail of that key is the form above, so a trace reading `@local.source.demo.beats` is `require("demo.beats")`. For a module in a **library** — content under `/zero/source/libs/@<lib>/` — `@<lib>::` addresses that library's root from anywhere: `require("@builtin::modules.transform")`. Mounting a world as a library moves its files under such a root and flips the caller root to `@<lib>::`, which is what keeps the root-relative forms above resolving across the move. ## Discovery - `asset.list("module")` — every registered module. - `asset.inspect("<name>")` — public functions, source path, this type README. - `ls /zero/source/<name>.module` lists the files the folder holds. ## Authoring conventions - Return a single `M` table from `init.luau`. Top-level statements with side effects run on first require — useful for one-time initialization, dangerous if they touch the engine before it's ready. - Write exported functions as `typed function`, not `function`: the argument types are enforced at the call and the checker knows the signature, so a caller's mistake is reported where they made it. - Annotate exported functions with `--!desc` / `--!arg` / `--!return` / `--!example` so the LSP, `tools.list`, and `asset.inspect` surface them. A function with no `--!desc` is reachable only by someone already reading this file. - Declare a VALUE with `Field.<kind>(default, mode, description)`, and a table whose members are reached through a metatable with `--!members <TypeName>` — the latter gives the surface its accepted member set, its type, and its documentation from one table. See `guides { path: "core/authoring" }` for all four rules and `modules/api/engine/entity.module/members.module` for a worked member table. - Keep module state in module-local upvalues or in `modules.state()`. A name the body declares without `local` lives in the module's environment, which every run of the module shares: a function the previous run left in place reads what the reload assigned. Module-locals start again from their initial values with each hot reload; the table `modules.state()` returns is the same table on both sides of every reload. - Prefer focused modules over kitchen-sink modules. If two halves of a module have no shared state, split them. ## Common pitfalls - **Cyclic requires.** A `require` chain that loops will return the partial table (the half built before the cycle was detected). Design for one-way dependencies. - **Engine-time side effects.** Don't `entity.spawn` at module top-level — the engine may not be ready. Expose an `M.init` and call it from a scene entrypoint. - **Hot-reload + module-local cache.** A module that caches expensive computation in upvalues loses that cache on reload — fine for dev, but be aware in performance work. - **`init.lua` vs `init.luau`.** Either works, `.luau` is canonical. - **Writing one while play runs.** A `/source` write made while play is running lands on the play shadow, and a guarded play-exit discards it unless it is kept. One `.module` write shadows `init.luau`, the `README.md` and the `.metadata` together. `vfs.write(path, bytes, { durable = true })` skips the shadow, and `vfs.promotePlayShadow` keeps what is already on it — the `core/vfs` guide has the model and what each route costs. ## Related types - `.component` — for entity-bound state + lifecycle hooks. - `.service` — generates content you don't have yet (a mesh, sound, texture, …) via a metered provider. - `.tool` — for a single agent-callable function with a YAML schema. - `.package` — to group several modules + components + scenes into one shippable folder. ## In the Inspector Exports: what the module exports (name, kind, signature), parsed from its source without running it, with **Check** (the LSP's diagnostics for its entry file) and **Open**.
▲ 0↑ born
backing path · systems/terrainSystem.package

Problems

Everything affecting this asset right now: its own problems, anything wrong inside it, and problems on its direct dependencies.

0problems
No problems reported on this asset or on the parts this page shows. Parts it leaves out are not counted here.
⌬ZeroMind agent review · awaiting first pass
Findings
Reviewer findings (handle · model · tag · quoted note) appear here once the per-pass review log lands. Today only the rolled-up agent_score is exposed.
usability—
did it work as advertised
quality—
authoring polish + cohesion
performance—
frame & memory budget held
agent review score
—
/ 100
awaiting first pass
usability × 0.40
+ quality × 0.35
+ performance × 0.25
± compat factor

Usability ratings

Did the part work as advertised when consumers tried to drop it in. Separate from upvotes: those are taste; this is "did it function".

—%no reports yet
Sign in to report whether this part worked for you.
Discussion

Scoped to this part · feeds back into the world's score.

0comments
Sign in to post.sign in
No comments yet. Be the first.