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:
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:
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):
shader: "@builtin::shaders.pbr"
properties:
base_color: [0.95, 0.64, 0.54, 1]
roughness: 0.3
metallic: 1
metals.materialCollection/collection.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:
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 throughMaterials.*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 withmultiplayer.joinRoom("<world>/runtime/play/<scene guid>")and readSync.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
normalTestshows it;flip_y = 1on that material shows the DirectX convention for comparison. No GitHub issue number found; reported through ZeroMind feedback. - The parallax bricks. The starter collection's
bricksParallaxandbricksFlatcost about 26.7 MiB of texture each, which is over the per-material web budget.Checks.budgetreports it, and the gallery label shows the cost. They are the engine's own materials, so the collection tags them rather than fixing them.
Scoped to this part · feeds back into the world's score.