# Lighting
Live-switchable lighting presets for the materialLibraryKit swatch gallery. One call changes the sun, ambient, sky, display exposure, the sky reflection the chrome balls mirror and the preset-only lights, for every player in the room. The gallery UI, the picker and any scene that wants a known light to judge materials under call it.
```luau
local Lighting = require("~materialLibraryKit.Lighting") -- from a world
-- local Lighting = require("~.Lighting") -- inside the package
Lighting.attach() -- find or build the rig (idempotent; call on every scene load)
Lighting.applyCurrent("daylight") -- show the room's preset, or daylight if nobody chose one yet
Lighting.set("goldenHour", { blend = 1.5 }) -- everyone switches, easing over 1.5 s
for _, p in Lighting.list() do print(p.id, p.name, p.description) end
local stop = Lighting.onChanged(function(id) print("lighting is now", id) end)
```
## API
| Call | What it does |
|---|---|
| `list()` | `{ { id, name, description } }` in display order. |
| `set(id, opts?)` → `ok, err` | Applies the preset here and writes it to the Sync channel `lighting` (key `preset`) so every peer, including late joiners, applies it too. `opts.localOnly` skips the broadcast. `opts.blend` (seconds) eases sun, ambient, sky colours and exposure. Unknown id → `false, "unknown lighting preset …"`. |
| `current()` | The preset id this peer shows (falls back to the synced one, then `daylight`). |
| `attach(root?)` | Ensures the rig exists and returns its entity ids. Reuses the scene's own directional `Light`, ambient `Light` and `ProceduralSky`, found **by component** (never by name), and spawns whatever is missing as local, temporary entities under `MLK_Lighting` (parented to `root` when given). Idempotent and cheap (~0.1 ms). |
| `applyCurrent(fallback?)` | `attach()`, then apply the synced preset (or `fallback`) on this peer only. Call this from a scene or component when it loads. |
| `onChanged(fn)` → unsubscribe | `fn(id)` on every change on this peer, local or remote. |
| `releaseDisplay()` | Hands the display exposure back to whatever set it before. |
## Presets
| id | Look | Sun | Ambient | Sky | Exposure | Extras |
|---|---|---|---|---|---|---|
| `daylight` | Sunny midday | high, white, 7.0, hard shadows | cool sky fill 0.12 | clear blue | 0.9 | — |
| `overcast` | Soft, flat, true colour | near-overhead 1.6, **no shadows** | bright neutral 0.55 | flat grey, no sun disc | 1.05 | — |
| `goldenHour` | Low warm sun | 14° elevation, orange, 7.5, long shadows | warm 0.12 | blue zenith → amber horizon | 1.2 | — |
| `moonlight` | Cool clear night | high blue moon 1.0, shadows | blue 0.3 | near-black blue | 1.8 | — |
| `warmInterior` | Night outside, tungsten inside | faint blue 0.25, no shadows | warm 0.3 | dark night | 1.8 | 6 unshadowed warm point lights on a 9 m ring + 1 shadowed spot over the centre |
| `studio` | Neutral reference | white key 4.5, shadows | white 0.12 | uniform mid-grey backdrop | 1.0 | 2 unshadowed distant lights (fill, rim) |
The sky is the scene's `ProceduralSky` with `syncSunToLight = false`, so the preset owns the sun and the sky is drawn from it. A preset switch recaptures the environment's sky slot (`environment.captureSky`) four frames later, so metals reflect the new sky.
## Cost
- No per-frame work. A blend runs one task for its duration and then exits.
- A switch costs about 1 ms of Lua, once.
- GPU cost: one shadowed sun in every preset. `warmInterior` adds 6 unshadowed points and 1 shadowed spot. `studio` adds 2 distant lights with no shadow maps.
- Preset-only lights are switched off with `setActive(false)`, which takes their rows out of the renderer.
- There is no fog. A height-fog pass costs a full-screen pass every frame and dimmed the sky reflection.
## Multiplayer
The preset id is the only synced state. Every peer builds its own rig locally from the id, so nothing heavy replicates. `Sync` resolves concurrent writes newest-wins and hands late joiners a snapshot.
# PlayWrites
The kit's writes to the scene's own entities during play (the sun, ambient
light and sky of a lighting look, a material another player picked), made
from a component's update so the engine never counts them as "tuned in play"
and Stop just works. `PlayWrites.run(key, fn)` runs `fn` at once outside play
and queues it during play; the `MaterialLibraryPlayWriter` component, which
the module adds to a temporary entity of its own, makes the queued writes
each frame. Lighting and Materials call it; a collection never needs to.
# Sync
Keeps small keyed state the same on every peer: the applied materials, the
gallery exhibit and the lighting preset. Each write is stamped with a Lamport
time and the peer id and broadcast with `multiplayer.send`; every peer keeps
the newest stamp. A peer that arrives asks for a snapshot, and peers push one
when a player joins, so late joiners converge. Called by Materials, Gallery
and Lighting; collections rarely need it directly.
# SwatchGallery
Drop on any entity to stand the materialLibraryKit swatch gallery around the
origin: it applies the room's lighting preset (`lighting`), builds the
exhibit (`view`) and shows the toolbar (`showHud`). It needs no sibling
components; put the player's spawn at the origin so every label faces it.
·materialcollection · born here
❒asset # Calibration shelf
The 20 neutral reference materials materialLibraryKit ships, so every
collection can be judged against known values:
- Cards: an 18% grey card, a lit white card, an unlit white and a lit black card.
- Balls: a chrome ball, a glossy 18% grey ball, a glossy black dielectric ball
(albedo 0.04, roughness 0.05, not metallic) and a matte diffuse 18% grey
ball (roughness 1).
- Ramps: roughness 0 to 1, metallic 0 to 1, PBR albedo 4% to 90%, and a shadow
ramp.
- Charts: a UV orientation checker, a normal-map test pattern, a colour checker
chart, a 21-stop grey wedge, a Siemens star and focus bars.
- Emitters: one at a plain 1.0 reference brightness and an HDR one at 16x.
The engine's own `postReference` materials are reused where they fit; the rest
live in this package's `materials/` folder. None of them uses a texture.
# Materials
The material library: every entry of every installed `.materialCollection`,
with its tags, plus `apply` / `clear` to put a library material on an entity
for every player. `list(filter)` finds materials by category, kind, style,
collection, tag, animation, texture cost or free text. Collections join by
being installed (the collection type's register hook), and lookups go through
literal `asset.list` enumerations so a published world can use it on the
gameplay path. See the package README for every call.
# Hud
The gallery toolbar: exhibit buttons (All, Close-up, Compare, Style), paging
and material cyclers for the current exhibit, the lighting presets, and the
button that opens the material picker. Desktop shows it top-left; a narrow
(phone) screen spans the top and turns the lighting chips into a cycler.
Every control goes through Gallery and Lighting, so it acts for every player.
# Checks
Quality checks for library materials: what a material looks like in every
render pass, whether it tiles without seams, whether it shimmers at distance,
what texture memory it costs, and whether it fits the web build's budget.
Collections (metals, woods, toon, pixel…) call it from their own test suites;
agents call the same operations through the `materialChecks` toolbox.
```luau
local Checks = require("~.Checks") -- inside the package
local Checks = require("~materialLibraryKit.Checks") -- from a world or testSuite
Checks.assertGood("chromeBall") -- errors with every failure listed
local report = Checks.run("@builtin::materials.water")
```
Every call takes a **library id** (`"grey18"`, or `"collection/id"`) or a
**material identity** (`"@builtin::materials.water"`). Probe entities are
temporary, spawned around x = z = 6000, drawn isolated where the capture allows
it, and despawned before the call returns, even when it fails. A call that left
one behind errors. Images default to `/tmp/materialChecks/` (the shell's
`/tmp`, which is `/zero/source/tmp`: scratch that never syncs or publishes).
This is test/tool code. It resolves assets and the builtin capture helpers at
run time, so never require it from a component or a scene entrypoint.
## API
| Call | Returns |
|---|---|
| `Checks.views(id, { path?, cell?, passes? })` | `{ id, material, path, cells = { { probe, pass, mean, min, max, p5, p50, p95, spread, crushed, clipped } } }`: a sphere, a flat tile and a shader ball, one row each, in the `final`, `albedo`, `roughness`, `metallic` and `normal` passes. Default path `/tmp/materialChecks/<id>_views.png`; `cell` is the cell size in px (256). Luma figures are 0-255 code values. |
| `Checks.seams(id, { path?, size?, pass?, textures? })` | `{ id, seamless, renderSeamless, texturesSeamless, score, boundaryDiff, interiorDiff, interiorPeak, image, pass, axes = { {axis, boundaryDiff, interiorDiff, interiorPeak, score, seamless} }, textures = { {slot, texture, width, height, format, wrapX, interiorX, scoreX, seamlessX, wrapY, interiorY, scoreY, seamlessY} }, thresholds }` |
| `Checks.shimmer(id, { dir?, width?, height?, threshold? })` | `{ id, near, far, farShifted, collage, shimmerScore, shiftDiff, noiseFloor, shimmering, threshold, shiftMetres }` |
| `Checks.textureMemory(id)` | `{ id, material, totalBytes, totalKB, declaredKB?, residentBytes, residentKB, blockFormat, blockCompressionSupported, rows = { { slot, texture, width, height, compression, mips, bytes, kb, resident? = { bytes, format, width, height, mipLevels, compressed } } } }` |
| `Checks.budget(id, { limits? })` | `{ id, material, ok, warnings = { string }, totalKB, totalMiB, limits }` |
| `Checks.budget(nil, opts?)` | `{ ok, count, failing = { id }, results = { [id] = … }, limits }` for every library material |
| `Checks.run(id, { views?, seams?, shimmer?, limits? })` | `{ id, material, ok, failures = { string }, views, seams, shimmer, textureMemory, budget }`. Pass `false` to skip a render step. |
| `Checks.assertGood(id, opts?)` | the report, or an error listing each failure. Skips `views` unless asked, since views only make pictures. |
## How each check measures
**views.** One collage, rows = probe and columns = pass, through the builtin
capture pipeline (`@builtin::tools.capture.shared`), so the per-cell luma
statistics are the same ones the `capture` tool reports. A diagnostic pass is
drawn magenta on a shader that lights itself and publishes no material.
**seams.** A runtime 4 m × 4 m plane with UVs 0..4 (4 × 4 tiles) is rendered
top-down, orthographic, exactly framed (`orthoHeight = 4`, 512 px, so a tile is
128 px), in the `albedo` pass, which is unlit, so lighting gradients cannot pose
as seams. When the albedo frame comes back magenta (the shader publishes no
albedo), it falls back to `final` and says so in `pass`. For each axis it takes
the mean colour difference between neighbouring pixel columns (or rows):
- `boundaryDiff`: at the three tile boundaries (the strongest of the 3 columns straddling each one, since filtering can split an edge),
- `interiorDiff`: everywhere else (columns within 2 px of a boundary are skipped),
- `interiorPeak`: 98th percentile of the interior, using the same 3-column max.
An axis is **seamless** when any of these holds:
`boundaryDiff <= 3` (no visible jump; `Checks.SEAM_MIN_DIFF`),
`boundaryDiff / interiorDiff <= 2.0` (`Checks.SEAM_RATIO`), or
`boundaryDiff <= interiorPeak * 1.1` (`Checks.SEAM_PEAK_MARGIN`: the boundary is no
stronger than the texture's own strongest edges, so a checker whose cell edge
falls on the border passes). `score` is the worst axis's ratio.
Each textured slot is also checked straight from its pixels
(`renderer.texture.loadCpu`, layer 0): the last column against the first
(`wrapX`) and the last row against the first (`wrapY`), compared with
every neighbouring column (or row) pair inside it, under the same rule (mean
along the edge, interior mean, 98th-percentile peak). Any
failing texture edge sets `texturesSeamless = false`, and `seamless` is
`renderSeamless and texturesSeamless`. An atlas (several images stacked in one
texture, like `@builtin::textures.array_texture`) never wraps across its
border. Pass `textures = false` to judge it on the render alone.
Verified on a fixture: a 64 px black-to-white gradient that does not wrap
scores u boundary 127 vs 2 inside (score 63, seam) and v 0 (seamless), and its
texture edge `wrapX` 255 vs 4.
**shimmer.** A 240 m runtime floor, with UVs repeating every `tileSize` metres from
the library entry. `near` looks at it from 1.5 m (fov 60). `far` looks at a
point 30 m away from 8 m up (about 15° grazing, fov 20, so the floor fills the
frame out to about 90 m). The far view is rendered three times with the clock
pinned (`deterministic`): once, once more unchanged (`noiseFloor`) and once
moved sideways by half a pixel's footprint at 30 m (`shiftDiff`).
`shimmerScore = shiftDiff - noiseFloor`, the mean absolute luma difference in
code values. **shimmering** when above `Checks.SHIMMER_THRESHOLD = 6.0`.
Calibration: a 1-px checker without mipmaps scores ~10.7 (visible moiré), the
same texture with mipmaps ~4.7 (the worst a filtered texture does), water and
the triplanar checker 0.2-1.3, flat materials 0.
**textureMemory.** The estimate is `Materials.measureTextures`: source image size,
the texture's `settings.yaml` (`maxDimension`, `compression`, `generateMipmaps`).
A sphere probe then draws the material once, and `asset.observe()` reports what the
renderer actually holds for each texture (`resident`). `blockFormat` is the
format block-compressed textures are held in on this device
(`renderer.textureMemory()`: bc7 desktop, astc4x4 phones, etc2).
**budget.** It warns when:
| Limit | Value | Source |
|---|---|---|
| Hard texture size | 8192 px | texture type README (`maxDimension` 1..8192, larger refused); topics/graphics-quality (a GPU that cannot hold 8192 texels is low tier); WebGPU's default `maxTextureDimension2D` |
| Texture size | 4096 px | texture type README: a new texture's default `maxDimension` |
| Uncompressed (`rgba8`/`rgba16`/`rgba32f`) | ≤ 1024 px (not for `style = pixel`) | texture type README "What each compression stores": rgba8 is 4 B/texel, uastc 1 B/texel (BC7 desktop, ASTC 4x4 phones, ETC2) |
| Total textures per material | 24 MiB | topics/graphics-quality: the low tier caps all textures at 384 MiB (medium 768), shared by 16 resident materials |
| `rgba32f` | never on the web | texture type README: not filterable in browsers, so the slot draws the shader's default |
| `rgba16` | warned | texture type README: half float (Rgba16Float) in every browser |
| `uastc` not power of two | warned | texture type README: each side is resampled to a power of two |
| Declared `textureKB` | within 25 % of measured | the collection's own claim against the measurement |
Override any limit per call: `Checks.budget(id, { limits = { maxMaterialMiB = 48 } })`.
## Known limits
- The calibration charts (`siemensStar`, `focusBars`, `colorChecker`, …) use the
`referenceChart` shader, which clamps UVs to 0..1. They are single charts, not
tiling materials, so seams and shimmer read them as one flat colour.
- `textureMemory` reads residency after one probe draw. A texture already
streamed down by the texture budget reports its current level.
- Renders go through the scene's own sun and sky. Lighting changes the `final`
cells, not the seams verdict, which is taken in `albedo`.
# Swatchbook
The materialLibraryKit showcase: a neutral matte floor, the sun, ambient
light and sky the lighting presets drive, a player spawn at the origin facing
+Z with an orbiting camera, and the SwatchGallery component, which shows
every material of every installed collection with the toolbar and picker.
# Gallery
The swatch gallery. One exhibit stands around the origin at a time: every
material on a ring of bays grouped by category (paged), a close-up of one
material near and far with a 40 m runway, an A/B compare, or a shelf filtered
by style. Each bay shows the material on a sphere, a cube, a 2 x 2 m floor tile
and a wall section mapped at the material's real-world tile size, plus a
shader ball and a cylinder, under a one-line-per-row label plaque sized to be
read from the spawn. Swatches are local temporary entities; the exhibit is
shared through Sync.
# Picker
The on-screen material picker for the materialLibraryKit library. It lists every material the
installed `.materialCollection`s provide, filters them by category and style family, and applies
the one you choose to a target entity for every player, through `Materials.apply`.
```luau
local Picker = require("~materialLibraryKit.Picker") -- from a world
-- local Picker = require("~.Picker") -- from inside the package
Picker.open({ style = "grid" }) -- "grid" | "list" | "quick"
Picker.setTarget(entity.find("crate"))
Picker.pick("gold") -- true, or false and why
Picker.onPicked(function(id, target, ok, err) print(id, ok, err) end)
```
## API
| Call | What it does |
|---|---|
| `Picker.open(opts?) -> (ok, err?)` | Shows the picker. `opts.style` picks its look. |
| `Picker.close()` | Hides it and unregisters its screen. |
| `Picker.toggle(opts?) -> boolean` | Opens or closes it, and returns whether it is open now. |
| `Picker.isOpen() -> boolean` | Whether it is showing. |
| `Picker.styles() -> { string }` | `{ "grid", "list", "quick" }`. |
| `Picker.style() -> string` | The look in use. |
| `Picker.setStyle(style) -> (ok, err?)` | Switches the look live. |
| `Picker.target() -> entity?` | The entity the materials go on. |
| `Picker.setTarget(entityOrIdOrName?) -> (ok, err?)` | Points the picker at an entity. Pass nil to clear it. |
| `Picker.pick(id) -> (ok, err?)` | Applies library material `id` to the target and shows the result or error in the panel. |
| `Picker.onPicked(fn) -> unsubscribe` | Calls `fn(id, target, ok, err)` after every pick. |
| `Picker.visible() -> { id }` | The ids the active filters list, in order. Useful for tests and tools. |
| `Picker.onCallback(id, data) -> boolean` | Routes a widget callback by hand. You don't normally need it, because the picker registers its own callback env. |
## The three looks
The Grid / List / Quick switch in the header changes the look live.
- **Grid** shows a thumbnail per material (its `preview.png`), with the name and `kind · style`
under it and the description and tags in the hover tooltip. Category and style chips filter
the grid. Click or tap a tile to apply it. The material on the target is outlined in blue.
- **List** shows a search box (it matches name, id, tags, kind and collection), the same chips,
and one row per material: name, `category / kind`, style family, tile size and texture KB.
Click or tap a row to apply it.
- **Quick** shows one big card at a time, for touch and gamepad. It has ‹ › cyclers for
category and style, big ◀ ▶ buttons and a big Apply button.
- Keyboard: ←/→ browse, Q/E cycle the category, ↑/↓ cycle the style, Enter applies.
- Gamepad: D-pad left/right browse, LB/RB cycle the category, D-pad up/down cycle the style,
and the south face button (A on Xbox, Cross on PlayStation) applies.
- Keys are ignored while a text field has focus.
## Targets
- **Edit mode:** the editor's selection is the target (primary of the `entity` selection scope),
and it follows the selection as it changes. The panel draws over the Scene view, on the
window, because the editor camera does not draw game UI.
- **Play mode:** a click or tap on an object makes it the target. The picker takes the hit
entity, or its nearest ancestor that has a Model. The target gets a blue outline through
`@builtin::modules.outline`, which is runtime state and never written into the scene. Presses
that land on the picker itself (or on any UI that took the pointer) are ignored. When the
pointer is locked, the camera-centre ray is used instead. The panel draws in the camera's
picture, inside the safe area.
- `setTarget` overrides either until the selection or a click changes it.
## Layout and cost
- At 640 points wide or more, Grid and List dock to the right edge and Quick docks bottom-right.
- Below 640 points (a phone), the picker is a bottom sheet: Quick stacks its cyclers, and List
narrows its right-hand column.
- Touch targets are 30–60 points.
- The widget tree is rebuilt only when what it shows changes: filters, search, library
(`Materials.onChanged("library")`), an applied material, the target, the canvas size, or a
mode flip. Grid rows and list rows sit in virtual scroll areas, so only the visible rows are
laid out.
- The frame loop is a `@builtin::modules.frameDriver` system task that runs only while the picker
is open. On a quiet frame it polls the pad (in Quick) and checks the layout every 0.25 s.
## Notes
- Thumbnails pass the short VFS path (`/source/.../preview.png`) to an `image` widget. The image
loader reads VFS paths directly, so the picker makes no computed `asset.resolve`.
- The screen is registered from the frame loop's system task, so no component instance owns it
and its callbacks always reach the picker's own env, whoever called `open`.
- State lives in `modules.state()`, so an open picker survives a hot reload of this module. The
previous load's loop and subscriptions stand down.
# materialChecks
Checks for material-library materials, the tool face of
`materialLibraryKit.Checks`: render passes, tiling seams, distance shimmer,
texture memory and the web texture budget. Every tool takes a library id
(`grey18`) or a material identity (`@builtin::materials.water`). Probes are
temporary and removed before a tool returns. Images land in `/tmp/materialChecks/`.
| Tool | What it does |
|---|---|
| `materialChecks.views` | Sphere, tile and shader ball in final/albedo/roughness/metallic/normal; saves a collage and returns per-cell luma |
| `materialChecks.seams` | Tiles 4 × 4, renders top-down in albedo and measures boundary vs interior jumps; checks each texture's opposite edges |
| `materialChecks.shimmer` | Near (1.5 m) and far (30 m, grazing) views; a half-pixel shift measures shimmer |
| `materialChecks.textureMemory` | Per-slot GPU texture bytes, estimated and actually resident |
| `materialChecks.budget` | Web-budget warnings for one material, or for the whole library with no id |
| `materialChecks.run` | Everything above in one report, with `ok` and readable failures |
From the shell: `zero materialChecks seams grey18`. From Luau:
`tools.use("materialChecks", "run", "chromeBall")`. Thresholds, limits and their
sources are in the `Checks` module README.
# materialLibraryKit
The shared core of a material library: tagged materials from every installed collection, `Materials.apply` / `Materials.list`, a calibration shelf, a swatch gallery, shared lighting presets, an on-screen picker, and checking tools. Version 1.3.0.
Collections (metals, woods, stone, fabrics, sci-fi panels, toon and pixel sets…) are separate packages that plug into this core. Once a collection is installed, its materials appear in the list, the picker and the gallery on every player's machine, with no code. Installing it is all a world has to do.
## Version history
**1.3.0 (current, 2026-10-21)**
- **Tests stay out of the live room.** New in `Sync`: `Sync.isolate()`, `Sync.endIsolation()`, `Sync.isolated()` and `Sync.onIsolation(fn)`. Every test of the kit runs inside an isolation: what a test writes for every player goes out tagged for that test alone, a player who joins meanwhile is sent the room's real state, and when the test ends the lighting look, the gallery and the applied materials are shown as the room has them. Collections should isolate their own tests the same way (see "Make your own collection").
- **The tests pass every time**, with the core installed by itself or with a collection. The lighting check after a look change only repairs lights that are there and never builds a rig of its own, so a test runner clearing the world between tests gets nothing back in the next test, and the checks' test counts only the probe entities the checks spawn.
- **A player who joins late sees the room's lighting look, not just its name.** On a machine still loading, a look could be written into a light the engine had not registered yet, and the scene's own sun and sky then arrived with their saved daylight. After every look change the kit checks for 20 s that the screen really draws it and applies it again when not. `Lighting.isShown()` answers the same question, and each player's room report carries `shown`.
- **Stop just works after using the lighting looks.** During play the engine counts a field written by a caller (the console, a tool, an agent, or a callback or background task one of them started) as "tuned in play" and asks to throw it away on Stop. The kit's writes to the scene's sun, ambient light and sky, and the materials it puts on scene objects, are made from a component's update during play (`PlayWrites` and its `MaterialLibraryPlayWriter` component, added for you), which the engine never counts.
- **Players arrive to a clean gallery.** The engine can hand one running copy of the kit from one world to the next (it keeps a module by its asset and version). `Sync` checks the bound world before it sends, takes in or reads anything; in a new world it drops what it held, asks the new room for its state and carries on there, so one world's state never reaches another world's players.
- A material applied to a shared entity that has not reached a player yet (one who just joined) is tried again there for 10 s.
- The README's first example spawns a shared ball, so every player really has it, and shows how to take it away again.
- The version is stated the same way in every place: package settings and description, this README, the asset listing, `Materials.VERSION`, `Checks.VERSION` and the calibration shelf. A test checks all of them.
- Built against the engine library of 2026-10-21 (`@builtin` 5b09a32). Two plain strings the publish check read as asset references no longer look like references.
- Tests: 38 (core 13, sync 13, tools 4, starter 8).
**1.2.1 (2026-10-20)**
- The core's own tests use only the core (the calibration shelf and engine materials), so they pass with the core installed by itself.
- The late-join test waits for the answer carrying its own write instead of taking the first message.
**1.2.0 (2026-10-20)**
- It installs again: the collection type no longer ships a `template/` folder, which a fresh install refused; `asset.create("materialCollection", name)` makes a new collection through the type's `onCreate`.
- Reach the core by its package name, `~materialLibraryKit`, wherever it was installed.
- `Materials.apply` and the picker find a swatch's `Model` whichever copy of the engine's `Model` component it carries.
- The gallery's style view shows a notice board when no installed material has that style, and the picker says the same.
- Late-join tests send the room's state through the engine's loopback transport.
**1.1.0 (2026-10-18)**
- Picker taps work with mouse and touch, take the point from the event, and say in the status line why a tap targeted nothing.
- Every library entry carries `displayName`, `subKind` and `sampledTextures`; `Checks.budget` judges the sampled-texture and storage-buffer limits.
- The calibration shelf grows to 20, adding a glossy black ball, a matte 18% grey ball and a 1.0 emitter.
- `Materials.list` refuses unknown filter keys and takes lists for its name keys.
- Lighting finds its extra lights by name on every look, so none can be left switched on.
- Some of these (the new entry fields, the three calibration materials and their tests) went in after 1.1.0 was first published, without a new number.
**1.0.0 (2026-10-18)**
- First release: tagged collections, `Materials.apply` / `Materials.list`, the calibration shelf, the swatch gallery and Swatchbook scene, shared lighting looks, the picker (grid, list, quick), the toolbar, material checks and tests.
## Install and use
Install the package into your world from ZeroMind: search "materialLibraryKit", or install the published package `0d7f3e76-0699-44fd-b0ac-2cb37822b648`. Installed by itself it lands at `/source/materialLibraryKit.package`. When it comes with a collection (starterMaterials, for example) it lands under `/source/deps/`. Either way, scripts reach it by its package name with `~materialLibraryKit`. Then, from any script or the console:
If you installed 1.3.0 before 18:00 UTC on 2026-10-21, install it again: it was published several times that day while it was being finished, and only the last of those is the 1.3.0 described here.
```luau
local Materials = require("~materialLibraryKit.Materials")
-- synced: the ball exists for every player, so every player sees its material
local ball = entity.spawn("ball", { synced = true, components = { Model = { model = "sphere" } } })
ball.localPosition = { 0, 1, 3 }
assert(Materials.apply(ball, "chromeBall")) -- every player sees it
for _, m in Materials.list({ category = "calibration", style = "realistic" }) do
print(m.id, m.kind, m.tileSize, m.textureKB)
end
```
`Materials.apply` returns `ok, err`; the `assert` stops with the reason instead of leaving the ball grey. When you are done with the ball, take it away again for every player:
```luau
Materials.clear(ball)
entity.despawn(ball.id)
```
To see every installed material, load the showcase scene, or drop the gallery into a scene of your own:
```luau
zero scene load materialLibraryKit.Swatchbook -- in the shell
entity.spawn("Gallery").component.add("SwatchGallery", {}) -- or in a scene's build.luau
```
The gallery stands around the origin, so put the player's spawn there. The Swatchbook scene does.
## The tags
Every library entry carries these fields. A collection writes them in its `collection.yaml`; the fields marked *filled* get a default when left out.
| Field | Meaning |
|---|---|
| `id` | Unique library id (letters, digits, `_`, `-`). If two collections use the same id, the second is reachable as `collection/id`. |
| `name` | Display name. *Filled* from the id ("chromeBall" becomes "Chrome Ball"). Every entry carries it as `displayName` (and as `name`). |
| `material` | The material asset: an identity (`@builtin::materials.gold`), a VFS path, or `~.a.b` for `<this package>/a/b.material`. |
| `category` | What it is: metal, wood, stone, fabric, calibration… Free text, lower-cased. |
| `kind` | The sub-kind within the category: steel, oak, grey card… *Filled* with the category. Every entry carries it as `subKind` (and as `kind`). |
| `style` | Style family, one of `realistic`, `stylised`, `toon`, `pixel`, `scifi`, `fantasy`. |
| `tileSize` | Metres one texture repeat covers. The gallery's floor tiles and walls use it, so each shows at real-world scale. *Filled* 1. |
| `roughness`, `metallic` | `[min, max]`, or a single number. *Filled* from the material's own values. |
| `textureKB` | GPU texture memory in KB. *Filled* by measuring the material's textures. |
| `sampledTextures` | How many textures the material samples per draw: every texture slot its shader declares (an unbound slot still samples its default). Always measured; a collection does not write it. `Checks.budget` holds it to at most 9. |
| `animated` | Whether the surface moves over time. *Filled* false. |
| `unlit` | A self-lit or reference-chart shader. *Filled* when the shader has no roughness or metallic. |
| `singleRepeat` | The material must be drawn once per face (UV 0..1), as a parallax march that discards outside one repeat does. *Filled* false. |
| `tags`, `description` | Free words for search and the picker's tooltips. |
## Every call
From a world use `require("~materialLibraryKit.<Module>")`, which finds the core wherever it was installed; inside the package use `require("~.<Module>")`.
**Materials** (the library)
| Call | Does |
|---|---|
| `Materials.list(filter?)` | Entries matching `{ category, kind, style, collection, tag (or tags), animated, search, maxTextureKB, includeBroken }`, sorted by category, kind, then name. For the name keys (`category`, `kind`, `style`, `collection`, `tag`/`tags`) a string or a non-empty list of strings matches any of them: `Materials.list({ tag = { "checker", "pattern" } })`. An unknown key, or a value of the wrong kind, raises an error naming it and the keys it knows, so a typo never returns the whole library. |
| `Materials.get(id)` | One entry, or nil. |
| `Materials.apply(target, id, opts?)` | Puts the material on the target's Model (or every Model under it) for every player. `target` is an entity, an id or a name. `opts.localOnly` keeps it on this peer. A player who has not built the entity yet (a shared entity still reaching someone who just joined) tries again for 10 s, and `reapply` catches up anything built later. Every player sees it only on an entity every player has: a shared (`synced`) entity, the scene's own entities, or the gallery's swatches. Returns `ok, err`. |
| `Materials.clear(target, opts?)` | Restores what the entity wore before, for every player. |
| `Materials.applied(target)` | The library id applied through the library, or nil. |
| `Materials.reapply()` | Re-applies shared assignments to entities built at runtime. Returns the count. |
| `Materials.keyOf(target)` | How peers name an entity: `id:<id>` for a synced entity; `id:<synced ancestor>/<path below it>` for a part of one (a piece of a player's body); `path:<name path>` for local ones. |
| `Materials.categories()`, `Materials.styles()`, `Materials.STYLES` | What the library holds, and the six style families. |
| `Materials.collections()` | `{ name, title, version, path, count, problem? }` per installed collection. |
| `Materials.problems()` | Entries breaking the tag contract: unknown style, missing material, duplicate id. |
| `Materials.refresh()` | Rescans every collection. Returns the material count. `list` already scans on first use, and new collections arrive by themselves. |
| `Materials.onChanged(fn)` | Calls `fn("library")` when collections change and `fn("applied", key, id)` when a material is applied. Returns an unsubscribe function. |
| `Materials.findMaterial(name)` | A material AssetRef by identity, path or guid, found without a computed resolve, so published worlds can call it. |
| `Materials.measureTextures(material)` | Total bytes, plus one row per textured slot: `{ slot, texture, width, height, compression, bytes, mips }`. |
| `Materials.VERSION` | `"1.3.0"`. |
**Gallery** (the swatch gallery: every material on a sphere, a cube, a 2 × 2 m floor tile and a wall section at real-world scale, plus a shader ball and a cylinder, with a label plaque)
| Call | Does |
|---|---|
| `Gallery.setView(view, opts?)` | Switches the exhibit for every player. `"all"` (by category, paged 16 per ring, `{ page }`), `"closeup"` (`{ id }`: near, plus a 40 m runway), `"compare"` (`{ a, b }`), `"style"` (`{ style, page }`). `opts.localOnly` keeps it on this peer. A style no installed material has stands a notice board in front of the spawn saying so (and listing the installed styles) instead of an empty ring. |
| `Gallery.build()`, `Gallery.clear()`, `Gallery.isBuilt()` | Stand, remove, or check the current exhibit. |
| `Gallery.view()`, `Gallery.pages()`, `Gallery.stats()` | The current exhibit, its page count, and `{ entities, bays, pages, radius, empty }` (`empty` is true while that notice stands). |
| `Gallery.onChanged(fn)` | Calls `fn(view)` after each rebuild. |
| `Gallery.VIEWS`, `Gallery.PAGE_SIZE` | The exhibit names and the bays per ring. |
| `Gallery.tour(settle?, measure?)`, `Gallery.tourResult()`, `Gallery.requestTour(settle?, measure?)` | Measure this peer's frame rate on an empty floor, every page, the close-up and the compare (local only; the exhibit is restored after). `requestTour` asks every other player's machine to run it; their results arrive in `Sync.heard()[peer].report.tour`. |
| `Gallery.placePlayer(body, delay?)`, `Gallery.freeSpot(mine?)`, `Gallery.SPOTS` | Stand a player on one of four spots 5 m from the centre, facing outward into their own quarter of the ring, so no player stands between another's camera and the signs. The Swatchbook scene calls it for each player. |
| `Gallery.fillNameTags()` | Replace placeholder `{PlayerName}` tags with "Visitor <peer>". The gallery component runs it every 2 s. |
**SwatchGallery** component: `view`, `lighting` (a preset id, or empty to leave the scene's lighting alone) and `showHud`. It builds the gallery, applies the shared lighting and shows the toolbar.
**Lighting** (presets every player sees: `daylight`, `overcast`, `goldenHour`, `moonlight`, `warmInterior`, `studio`)
| Call | Does |
|---|---|
| `Lighting.list()` | `{ id, name, description }` in display order. |
| `Lighting.set(id, opts?)` | Switches for everyone. `opts.localOnly` keeps it local; `opts.blend` eases the change over that many seconds. |
| `Lighting.current()` | The active preset id. |
| `Lighting.isShown()` | Whether this machine's screen really draws the current preset (the scene's sun is the rig's, and the sun, ambient light and sky carry the preset's values). For 20 s after each change the kit re-applies a preset that is not shown, so a player still loading gets it; within that window a hand edit to the scene's sun or sky is put back. It only repairs lights that are there and never builds a rig of its own. |
| `Lighting.attach(root?)` | Finds the scene's sun, ambient light and sky, or builds its own. |
| `Lighting.applyCurrent(fallback?)` | Attaches and shows the room's preset. This is what a scene calls on load. |
| `Lighting.onChanged(fn)`, `Lighting.releaseDisplay()` | Subscribe to changes; hand the exposure back to the scene. |
**Picker** (styles `grid` with thumbnails, `list` with search, and `quick` for gamepad or phone)
| Call | Does |
|---|---|
| `Picker.open({ style? })`, `Picker.close()`, `Picker.toggle()`, `Picker.isOpen()` | Show and hide the picker. |
| `Picker.styles()`, `Picker.style()`, `Picker.setStyle(s)` | Switch the look. |
| `Picker.target()`, `Picker.setTarget(e)` | The entity a pick goes on: the editor selection in edit mode, a clicked or tapped object in play. |
| `Picker.pick(id)`, `Picker.onPicked(fn)`, `Picker.visible()` | Apply a material; get told about each pick; list the ids the filters show. |
| `Picker.pointerPixels()`, `Picker.lastTap()` | Where the pointer is in scene pixels; what the last tap saw (its point, the ray, each surface it crossed and why it was taken or skipped). A tap that targets nothing also says why in the picker's status line. |
| `Picker.pickAt(x, y)` | What a tap at scene pixel (x, y) targets: the nearest drawn surface with a Model, skipping players' bodies and the gallery's boards and frames. |
In play, a click or a touch tap on any swatch (or any object with a Model) targets it; presses on the toolbar and the picker stay in the UI. In Quick, ←/→ or the D-pad browses, Q/E or LB/RB changes category, ↑/↓ changes style, and Enter or A applies.
**Hud** (the gallery toolbar): `Hud.show()`, `Hud.hide()`, `Hud.isShown()`, and `Hud.onCallback(id)` to run a toolbar action by its id.
**Checks** (for a collection's own tests; tool code, so never require it from a component or scene entrypoint)
| Call | Does |
|---|---|
| `Checks.views(id, opts?)` | Renders the material on a sphere, a tile and a shader ball in the final, albedo, roughness, metallic and normal passes. Writes a PNG and returns per-cell luma stats. |
| `Checks.seams(id, opts?)` | Tiles the material 4 × 4, renders it from the top, and compares tile boundaries with tile interiors. Also checks each texture's opposite edges. Returns `{ seamless, score, ... }`. |
| `Checks.shimmer(id, opts?)` | Near (about 1.5 m) and far (about 30 m, grazing) images, plus a shimmer score from a half-pixel camera shift. |
| `Checks.textureMemory(id)` | Bytes per slot, cross-checked against what the renderer actually holds. |
| `Checks.budget(id or nil, opts?)` | Judges a material against the engine's per-material limits and the web budget. `bindings.verdict` is `"pass"` or `"fail"` for at most 9 sampled textures and 1 storage buffer, with the counts (`sampledTextures`, `storageBuffers`) and the shader's slots; every slot the shader declares counts, since an unbound slot still samples its default. The size checks warn on 24 MiB of texture per material, 4096 px, uncompressed textures over 1024 px, and formats browsers cannot filter. `ok` is false when any of these fail. `nil` checks the whole library; `opts.limits` overrides any limit. |
| `Checks.run(id, opts?)`, `Checks.assertGood(id)` | Everything at once; `assertGood` raises a readable failure, for test suites. |
The same six run as tools: `zero materialChecks run chromeBall`, or `tools.use("materialChecks", "budget", "gold")`.
**Sync** (shared state the other modules ride on): `Sync.channel(name, onRemote)`, `Sync.set(ch, key, value)`, `Sync.get(ch, key)`, `Sync.resync()`. Writes are newest-wins on every peer, and a player who joins later receives a snapshot; a peer also re-sends its state whenever another peer appears.
Keeping tests out of the live room: `Sync.isolate()` starts an isolation on this peer and `Sync.endIsolation()` ends it, `Sync.isolated()` says whether one runs, and `Sync.onIsolation(fn)` calls `fn("begin")` / `fn("end")` for state a module shows that no shared key restores. While isolated, every write meant for every player goes out tagged for that isolation alone, so no other player takes it in; the room's own writes keep arriving into its saved state, and a player who joins is sent that real state. Ending it brings the room's state back and shows it again (lighting look, gallery exhibit and whether it stood, applied materials), with whatever the room changed meanwhile. A test suite calls the pair in `beforeEach` / `afterEach`.
A copy of `Sync` keeps its state for the world that is bound: the engine can hand one running copy from one world to the next, so before it sends, takes in or reads anything it checks the world, and in a new one it drops everything it held and asks the new room for its state. One world's shared state never reaches another world's players.
**PlayWrites** (for the kit's own modules): `PlayWrites.run(key, fn)` runs a write to the scene's entities at once outside play, and during play from the `MaterialLibraryPlayWriter` component's update (added to a temporary `MLK_PlayWriter` entity when first needed), so the engine never counts it as tuning and Stop just works. Lighting and Materials use it; a collection does not need to.
Checking what every player actually shows (and how fast it runs): `Sync.perf()` is this peer's frame rate over the last second, `Sync.report()` is this peer's own report (the lighting preset and sun, and per shared entity the material wanted and the one on its Model), `Sync.heard()` is the latest report each other peer broadcast (after every change and every 15 s), `Sync.askRoom(seconds)` asks the room and waits for answers, `Sync.peers()` lists the peers, and `Sync.reporter(name, fn)` adds a section of your own.
**materialCollection** assets: `ref:entries()`, `ref:document()`, `ref:inspect()`.
## The calibration shelf
The core ships the `calibration` collection, 20 references:
- Cards: an 18% grey card (`grey18`), a lit white card (`whiteCard`), an unlit white (`whiteReference`) and a lit black card (`blackCard`).
- Balls: a chrome ball (`chromeBall`), a glossy 18% grey ball (`greyGlossy`), a glossy black dielectric ball (`blackGlossy`: albedo 0.04, roughness 0.05, not metallic) and a matte diffuse 18% grey ball (`matteGrey`: roughness 1).
- Ramps: roughness 0 to 1 (`roughnessRamp`), metallic 0 to 1 (`metallicRamp`), PBR albedo 4% to 90% (`albedoRange`) and a shadow ramp (`darkRamp`).
- Charts: a UV orientation checker (`uvChecker`), a normal-map test pattern (`normalTest`), a colour checker (`colorChecker`), a 21-stop grey wedge (`greyWedge`), a Siemens star (`siemensStar`) and focus bars (`focusBars`).
- Emitters: one at a plain 1.0 reference brightness (`emitterUnit`) and an HDR one at 16x (`emitterReference`).
The engine's own `postReference` materials are reused where they fit. Every calibration material is procedural and costs no texture memory.
## Make your own collection
A collection is a package holding one `<name>.materialCollection` folder. Here are two materials of your own:
```
myMetals.package/
package.yaml name: myMetals / version: "1.0.0"
README.md
materials/copper.material/mat.yaml
materials/blueSteel.material/mat.yaml
metals.materialCollection/
collection.yaml
README.md
.metadata { "tags": ["materials", "metal"] }
```
`materials/copper.material/mat.yaml` (`blueSteel` is the same with its own colour and roughness):
```yaml
shader: "@builtin::shaders.pbr"
properties:
base_color: [0.95, 0.64, 0.54, 1]
roughness: 0.3
metallic: 1
```
`metals.materialCollection/collection.yaml`:
```yaml
title: "My metals"
version: "1.0.0"
materials:
- id: copper
material: "~.materials.copper"
category: metal
kind: copper
style: realistic
tileSize: 1.0
tags: [warm, polished]
- id: blueSteel
material: "~.materials.blueSteel"
category: metal
kind: steel
style: scifi
tileSize: 0.5
```
That is the whole collection. Once it is installed, `Materials.list({ collection = "metals" })` returns both, and the picker and gallery show them on every player's machine. Check them in your collection's tests, isolated from the live room so a test run never changes what players see:
```luau
local Test = asset.containing(__FILE__).modules.shared
local Checks = require("~materialLibraryKit.Checks")
local Sync = require("~materialLibraryKit.Sync")
Test.describe("myMetals", function()
Test.beforeEach(function() Sync.isolate() end) -- nothing a test writes reaches the room
Test.afterEach(function() Sync.endIsolation() end) -- and the room's state is shown again after it
Test.it("copper passes every check", function()
Checks.assertGood("copper")
end)
end)
return true
```
The `starterMaterials` package is a worked example that only tags materials living elsewhere (the engine's own) without copying them.
## Multiplayer and the web
Applied materials, the gallery exhibit and the lighting preset are shared through `Sync`. The lighting rig is local to each machine; every look finds its own extra lights (studio fill and rim, warm practicals) by name, keeps exactly one of each and switches them to that look, so a player who joins, or a reload, cannot leave one switched on. Every peer converges on the newest write, and a player who joins later is sent the room's state: the test suite sends that snapshot through the engine's loopback transport and checks that the material, the lighting look and the gallery view all take it. Running the tests changes nothing other players see: each test runs inside `Sync.isolate()`, which keeps what it writes off the room and answers players who join from the room's real state, and the suite checks that the room's state and messages are untouched afterwards. Gallery swatches are local, temporary entities rebuilt on every peer, so they cost the network nothing. A full page of the gallery is about 183 entities: 16 bays, each with 7 models and 2 text blocks (name, facts). In a browser tab every page and view measured 47-60 fps (`Gallery.requestTour` measures any player's machine).
## Known limits
Engine problems are listed by their GitHub issue number (OrigoZero/zero) and only while they are open.
- **Installed textures can draw plain white on the desktop app (gh#20193).** On the desktop app, the textures of a freshly installed package do not load, so a collection's textured materials can show plain white. The calibration shelf is procedural and has no textures; textured collections (woods, metals) are.
- **No second player inside one engine (gh#19114).** One engine is one peer, so two-player behaviour cannot be tested from a single editor. The kit's tests send the late-join snapshot through the engine's loopback transport, and live checks use a second client (a browser tab or another device on the world's play page). `Sync.askRoom(seconds)` asks every peer what it actually shows.
- **No `asset.materialCollection.*` namespace (gh#1889).** The engine reserves the type-scoped global namespace but does not build it yet. Collections are reached through `Materials.*` instead.
- **Normal-map convention undocumented (gh#19414).** The engine reads a normal map's green channel as image-up (OpenGL/glTF). The calibration `normalTest` shows it; `flip_y = 1` on that material shows the DirectX convention for comparison.
- **The editor and players are in different rooms.** The engine keys rooms `{world}/{profile}/{mode}/{scene}`, so an editor in play never shares state with players of the published world. Two players of the published world do. To watch players from the editor, join their room with `multiplayer.joinRoom("<world>/runtime/play/<scene guid>")` and read `Sync.heard()`. This is the engine's design, not a bug.
- **Hidden browser tabs throttle.** A player whose tab is in the background catches up with lighting and material changes when the tab comes back.
- **The parallax bricks.** The starter collection's `bricksParallax` and `bricksFlat` cost about 26.7 MiB of texture each, which is over the per-material web budget. `Checks.budget` reports it, and the gallery label shows the cost. They are the engine's own materials, so the collection tags them rather than fixing them.
# materialLibraryKit
The shared core of a material library: tagged materials from every installed collection, `Materials.apply` / `Materials.list`, a calibration shelf, a swatch gallery, shared lighting presets, an on-screen picker, and checking tools. Version 1.0.0.
Collections (metals, woods, stone, fabrics, sci-fi panels, toon and pixel sets…) are separate packages that plug into this core. Installing one is all a world does: its materials appear in the list, the picker and the gallery on every player's machine, with no code.
## Install and use
Install the package into your world from ZeroMind (search "materialLibraryKit", or install asset `fb19426f-3987-440e-a42d-d37b59588a34`). It lands at `/source/materialLibraryKit.package`. Then, from any script:
```luau
local Materials = require("@root::materialLibraryKit.Materials")
local ball = entity.spawn("ball", { components = { Model = { model = "sphere" } } })
ball.localPosition = { 0, 1, 3 }
Materials.apply(ball, "chromeBall") -- every player sees it
for _, m in Materials.list({ category = "calibration", style = "realistic" }) do
print(m.id, m.kind, m.tileSize, m.textureKB)
end
```
To see every installed material, load the showcase scene, or drop the gallery into a scene of your own:
```luau
zero scene load materialLibraryKit.Swatchbook -- in the shell
entity.spawn("Gallery").component.add("SwatchGallery", {}) -- or in a scene's build.luau
```
The gallery stands around the origin, so put the player's spawn there. The Swatchbook scene does.
## The tags
Every library entry carries these fields. A collection writes them in its `collection.yaml`; the fields marked *filled* get a default when left out.
| Field | Meaning |
|---|---|
| `id` | Unique library id (letters, digits, `_`, `-`). If two collections use the same id, the second is reachable as `collection/id`. |
| `name` | Display name. *Filled* from the id ("chromeBall" becomes "Chrome Ball"). |
| `material` | The material asset: an identity (`@builtin::materials.gold`), a VFS path, or `~.a.b` for `<this package>/a/b.material`. |
| `category` | What it is: metal, wood, stone, fabric, calibration… Free text, lower-cased. |
| `kind` | The sub-kind within the category: steel, oak, grey card… *Filled* with the category. |
| `style` | Style family, one of `realistic`, `stylised`, `toon`, `pixel`, `scifi`, `fantasy`. |
| `tileSize` | Metres one texture repeat covers. The gallery's floor tiles and walls use it, so each shows at real-world scale. *Filled* 1. |
| `roughness`, `metallic` | `[min, max]`, or a single number. *Filled* from the material's own values. |
| `textureKB` | GPU texture memory in KB. *Filled* by measuring the material's textures. |
| `animated` | Whether the surface moves over time. *Filled* false. |
| `unlit` | A self-lit or reference-chart shader. *Filled* when the shader has no roughness or metallic. |
| `singleRepeat` | The material must be drawn once per face (UV 0..1), as a parallax march that discards outside one repeat does. *Filled* false. |
| `tags`, `description` | Free words for search and the picker's tooltips. |
## Every call
From a world use `require("@root::materialLibraryKit.<Module>")`; inside the package use `require("~.<Module>")`.
**Materials** (the library)
| Call | Does |
|---|---|
| `Materials.list(filter?)` | Entries matching `{ category, kind, style, collection, tag, animated, search, maxTextureKB, includeBroken }`. A string or a list of strings matches any of them. Sorted by category, kind, then name. |
| `Materials.get(id)` | One entry, or nil. |
| `Materials.apply(target, id, opts?)` | Puts the material on the target's Model (or every Model under it) for every player. `target` is an entity, an id or a name. `opts.localOnly` keeps it on this peer. Returns `ok, err`. |
| `Materials.clear(target, opts?)` | Restores what the entity wore before, for every player. |
| `Materials.applied(target)` | The library id applied through the library, or nil. |
| `Materials.reapply()` | Re-applies shared assignments to entities built at runtime. Returns the count. |
| `Materials.keyOf(target)` | How peers name an entity: `id:<id>` for a synced entity; `id:<synced ancestor>/<path below it>` for a part of one (a piece of a player's body); `path:<name path>` for local ones. |
| `Materials.categories()`, `Materials.styles()`, `Materials.STYLES` | What the library holds, and the six style families. |
| `Materials.collections()` | `{ name, title, version, path, count, problem? }` per installed collection. |
| `Materials.problems()` | Entries breaking the tag contract: unknown style, missing material, duplicate id. |
| `Materials.refresh()` | Rescans every collection. Returns the material count. `list` already scans on first use, and new collections arrive by themselves. |
| `Materials.onChanged(fn)` | Calls `fn("library")` when collections change and `fn("applied", key, id)` when a material is applied. Returns an unsubscribe function. |
| `Materials.findMaterial(name)` | A material AssetRef by identity, path or guid, found without a computed resolve, so published worlds can call it. |
| `Materials.measureTextures(material)` | Total bytes, plus one row per textured slot: `{ slot, texture, width, height, compression, bytes, mips }`. |
| `Materials.VERSION` | `"1.0.0"`. |
**Gallery** (the swatch gallery: every material on a sphere, a cube, a 2 × 2 m floor tile and a wall section at real-world scale, plus a shader ball and a cylinder, with a label plaque)
| Call | Does |
|---|---|
| `Gallery.setView(view, opts?)` | Switches the exhibit for every player. `"all"` (by category, paged 16 per ring, `{ page }`), `"closeup"` (`{ id }`: near, plus a 40 m runway), `"compare"` (`{ a, b }`), `"style"` (`{ style, page }`). `opts.localOnly` keeps it on this peer. |
| `Gallery.build()`, `Gallery.clear()`, `Gallery.isBuilt()` | Stand, remove, or check the current exhibit. |
| `Gallery.view()`, `Gallery.pages()`, `Gallery.stats()` | The current exhibit, its page count, and `{ entities, bays, pages, radius }`. |
| `Gallery.onChanged(fn)` | Calls `fn(view)` after each rebuild. |
| `Gallery.VIEWS`, `Gallery.PAGE_SIZE` | The exhibit names and the bays per ring. |
| `Gallery.placePlayer(body, delay?)`, `Gallery.freeSpot(mine?)`, `Gallery.SPOTS` | Stand a player on one of four spots 5 m from the centre, facing outward into their own quarter of the ring, so no player stands between another's camera and the signs. The Swatchbook scene calls it for each player. |
| `Gallery.fillNameTags()` | Replace placeholder `{PlayerName}` tags with "Visitor <peer>". The gallery component runs it every 2 s. |
**SwatchGallery** component: `view`, `lighting` (a preset id, or empty to leave the scene's lighting alone) and `showHud`. It builds the gallery, applies the shared lighting and shows the toolbar.
**Lighting** (presets every player sees: `daylight`, `overcast`, `goldenHour`, `moonlight`, `warmInterior`, `studio`)
| Call | Does |
|---|---|
| `Lighting.list()` | `{ id, name, description }` in display order. |
| `Lighting.set(id, opts?)` | Switches for everyone. `opts.localOnly` keeps it local; `opts.blend` eases the change over that many seconds. |
| `Lighting.current()` | The active preset id. |
| `Lighting.attach(root?)` | Finds the scene's sun, ambient light and sky, or builds its own. |
| `Lighting.applyCurrent(fallback?)` | Attaches and shows the room's preset. This is what a scene calls on load. |
| `Lighting.onChanged(fn)`, `Lighting.releaseDisplay()` | Subscribe to changes; hand the exposure back to the scene. |
**Picker** (styles `grid` with thumbnails, `list` with search, and `quick` for gamepad or phone)
| Call | Does |
|---|---|
| `Picker.open({ style? })`, `Picker.close()`, `Picker.toggle()`, `Picker.isOpen()` | Show and hide the picker. |
| `Picker.styles()`, `Picker.style()`, `Picker.setStyle(s)` | Switch the look. |
| `Picker.target()`, `Picker.setTarget(e)` | The entity a pick goes on: the editor selection in edit mode, a clicked or tapped object in play. |
| `Picker.pick(id)`, `Picker.onPicked(fn)`, `Picker.visible()` | Apply a material; get told about each pick; list the ids the filters show. |
| `Picker.pickAt(x, y)` | What a tap at scene pixel (x, y) targets: the nearest drawn surface with a Model, skipping players' bodies and the gallery's boards and frames. |
In Quick, ←/→ or the D-pad browses, Q/E or LB/RB changes category, ↑/↓ changes style, and Enter or A applies.
**Hud** (the gallery toolbar): `Hud.show()`, `Hud.hide()`, `Hud.isShown()`, and `Hud.onCallback(id)` to run a toolbar action by its id.
**Checks** (for a collection's own tests; tool code, so never require it from a component or scene entrypoint)
| Call | Does |
|---|---|
| `Checks.views(id, opts?)` | Renders the material on a sphere, a tile and a shader ball in the final, albedo, roughness, metallic and normal passes. Writes a PNG and returns per-cell luma stats. |
| `Checks.seams(id, opts?)` | Tiles the material 4 × 4, renders it from the top, and compares tile boundaries with tile interiors. Also checks each texture's opposite edges. Returns `{ seamless, score, ... }`. |
| `Checks.shimmer(id, opts?)` | Near (about 1.5 m) and far (about 30 m, grazing) images, plus a shimmer score from a half-pixel camera shift. |
| `Checks.textureMemory(id)` | Bytes per slot, cross-checked against what the renderer actually holds. |
| `Checks.budget(id or nil, opts?)` | Warns when a material is over the web build's limits: 24 MiB of texture per material, 4096 px, uncompressed textures over 1024 px, formats browsers cannot filter. `nil` checks the whole library. |
| `Checks.run(id, opts?)`, `Checks.assertGood(id)` | Everything at once; `assertGood` raises a readable failure, for test suites. |
The same six run as tools: `zero materialChecks run chromeBall`, or `tools.use("materialChecks", "budget", "gold")`.
**Sync** (shared state the other modules ride on): `Sync.channel(name, onRemote)`, `Sync.set(ch, key, value)`, `Sync.get(ch, key)`, `Sync.resync()`. Writes are newest-wins on every peer, and a player who joins later receives a snapshot; a peer also re-sends its state whenever another peer appears.
Checking what every player actually shows: `Sync.report()` is this peer's own report (the lighting preset and sun, and per shared entity the material wanted and the one on its Model), `Sync.heard()` is the latest report each other peer broadcast (after every change and every 15 s), `Sync.askRoom(seconds)` asks the room and waits for answers, `Sync.peers()` lists the peers, and `Sync.reporter(name, fn)` adds a section of your own.
**materialCollection** assets: `ref:entries()`, `ref:document()`, `ref:inspect()`.
## The calibration shelf
The core ships the `calibration` collection: an 18% grey card, white (lit and unlit) and black references, a chrome ball, a glossy grey ball, roughness and metallic ramps, an albedo range strip, a UV orientation checker, a normal-map test pattern, a colour checker, a 21-stop grey wedge, a Siemens star, focus bars, a shadow ramp and an HDR emitter. The engine's own `postReference` materials are reused where they fit. Every calibration material is procedural and costs no texture memory.
## Make your own collection
A collection is a package holding one `<name>.materialCollection` folder. Here are two materials of your own:
```
myMetals.package/
package.yaml name: myMetals / version: "1.0.0"
README.md
materials/copper.material/mat.yaml
materials/blueSteel.material/mat.yaml
metals.materialCollection/
collection.yaml
README.md
.metadata { "tags": ["materials", "metal"] }
```
`materials/copper.material/mat.yaml` (`blueSteel` is the same with its own colour and roughness):
```yaml
shader: "@builtin::shaders.pbr"
properties:
base_color: [0.95, 0.64, 0.54, 1]
roughness: 0.3
metallic: 1
```
`metals.materialCollection/collection.yaml`:
```yaml
title: "My metals"
version: "1.0.0"
materials:
- id: copper
material: "~.materials.copper"
category: metal
kind: copper
style: realistic
tileSize: 1.0
tags: [warm, polished]
- id: blueSteel
material: "~.materials.blueSteel"
category: metal
kind: steel
style: scifi
tileSize: 0.5
```
That is the whole collection. Once it is installed, `Materials.list({ collection = "metals" })` returns both, and the picker and gallery show them on every player's machine. Check them in your collection's tests:
```luau
local Checks = require("@root::materialLibraryKit.Checks")
Checks.assertGood("copper")
```
The `starterMaterials` package is a worked example that only tags materials living elsewhere (the engine's own) without copying them.
## Multiplayer and the web
Applied materials, the gallery exhibit and the lighting preset are shared through `Sync`. Every peer converges on the newest write, and a player who joins later is sent the room's state. Gallery swatches are local, temporary entities rebuilt on every peer, so they cost the network nothing. A page of the gallery is about 215 entities: 16 bays, each with 7 models and 4 one-line labels. In the editor build it measured about 5 ms of GPU time per frame and about 1 ms of label upkeep.
## Known limits
- **No `asset.materialCollection.*` namespace.** The engine reserves the type-scoped global namespace but does not build it yet (gh#1889). Collections are reached through `Materials.*` instead.
- **The editor and players are in different rooms.** The engine keys rooms `{world}/{profile}/{mode}/{scene}`, so an editor in play never shares state with players of the published world. Two players of the published world do: the shared lighting and materials were checked live with two browser tabs. To watch players from the editor, join their room with `multiplayer.joinRoom("<world>/runtime/play/<scene guid>")` and read `Sync.heard()`. This is the engine's design, not a bug, so there is no issue number.
- **Hidden browser tabs throttle.** A player whose tab is in the background catches up with lighting and material changes when the tab comes back.
- **Placeholder name tags.** The engine's player name tag sometimes keeps reading `{PlayerName}` for another player. The gallery fills such a tag with "Visitor <peer>". No GitHub issue number found; reported through ZeroMind (bug report, 2026-10-18).
- **No in-engine second peer.** `multiplayer.loopback()` drives engine component sync, not custom message channels, so two-player behaviour cannot be tested from one engine. No GitHub issue number found; reported through ZeroMind feedback.
- **Normal-map convention undocumented.** The engine reads a normal map's green channel as image-up (OpenGL/glTF). The calibration `normalTest` shows it; `flip_y = 1` on that material shows the DirectX convention for comparison. No GitHub issue number found; reported through ZeroMind feedback.
- **The parallax bricks.** The starter collection's `bricksParallax` and `bricksFlat` cost about 26.7 MiB of texture each, which is over the per-material web budget. `Checks.budget` reports it, and the gallery label shows the cost. They are the engine's own materials, so the collection tags them rather than fixing them.
# materialCollection (asset type)
A materialCollection is one installable shelf of the materialLibraryKit
material library: a `collection.yaml` that lists material assets and the tags
that describe each one. The core (`materialLibraryKit`) discovers every
collection in the world, on every peer, and merges their entries into the
library that `Materials.list`, `Materials.apply`, the picker and the swatch
gallery read. Installing a package that holds a collection is all a world does
to get its materials into the library.
## Folder shape
- `collection.yaml` — the entries. **Required.**
- `README.md` — what the collection holds. **Required.**
- `.metadata` — tags for search. **Required.**
## collection.yaml
```yaml
title: "Metals" # shown in the picker and gallery
version: "1.0.0"
materials:
- id: brushedSteel # unique library id (letters, digits, _ -)
material: "~.materials.brushedSteel" # identity, VFS path, or ~. (this package)
name: "Brushed steel"
category: metal # free text, lower case
kind: steel # sub-kind inside the category
style: realistic # realistic | stylised | toon | pixel | scifi | fantasy
tileSize: 1.0 # metres one texture repeat covers
roughness: [0.25, 0.4] # min, max (one number means min = max)
metallic: [1, 1]
textureKB: 2730 # optional: estimated GPU texture memory; measured when omitted
animated: false
tags: [brushed, anisotropic-look]
description: "Satin steel sheet."
```
Any field but `id`, `material`, `category` and `style` may be left out; the
core fills defaults (see the materialLibraryKit README). `~.` means the
package the collection file lives in, so a collection keeps working wherever
its package is installed.
# budget
Judges a material against the engine's per-material limits (a pass or fail verdict for at most 9 sampled textures and 1 storage buffer, with the counts) and warns when it exceeds the web build's texture budget: size over 4096 (hard 8192), uncompressed textures over 1024 px, rgba32f or rgba16 in browsers, more than 24 MiB per material, or a declared `textureKB` that is wrong. With no id it checks the whole library. Limits and their sources are in the `Checks` README. `zero materialChecks budget`.
# run
Runs every material check (views, seams, shimmer, texture memory and web budget) and returns one report with `ok` and a readable line per failure. Use it before adding a material to a collection. `zero materialChecks run chromeBall`. A testSuite should call `Checks.assertGood(id)` instead, which raises on failure.
# seams
Checks whether a material tiles without visible seams. It renders a 4 x 4 tiling top-down in the albedo pass, compares pixel jumps at the tile boundaries with jumps inside the tiles per axis, and compares each texture's opposite edges directly. Returns `seamless`, the scores and the image path. Thresholds are in the `Checks` README. `zero materialChecks seams @builtin::materials.water`.
# materialChecks shared
Result envelopes (`ok`, `fail`) and the `yn` formatter shared by the
`materialChecks` tools. The checks themselves live in `materialLibraryKit.Checks`.
# shimmer
Shows a material at 1.5 m and at 30 m on a grazing floor and measures shimmer (aliasing / moiré): two far renders half a pixel apart are differenced, and the noise of two identical renders is subtracted. Shimmering above 6 code values. Saves near, far and collage PNGs. `zero materialChecks shimmer chromeBall`.
# textureMemory
Reports the GPU texture memory a material costs, per slot: an estimate from the source images and texture settings, and the bytes and format the renderer actually holds after one probe draw. `zero materialChecks textureMemory @builtin::materials.water`.
# views
Renders a material on a sphere, a flat tile and a shader ball in the `final`, `albedo`, `roughness`, `metallic` and `normal` passes and saves one collage PNG (rows = probe, columns = pass), with per-cell luma statistics. Call it to see whether a material reads correctly in every channel, for example a metal whose metallic cell is black, or a normal map that is missing. `zero materialChecks views grey18`. Implemented by `materialLibraryKit.Checks.views`.