# 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("@root::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`.
# 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.
# 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.
# 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("@root::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.
# 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.
# 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("@root::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.
# 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.
# 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.
# 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.
·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.
# 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.
# 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.
# albedoRange
The PBR-valid albedo range as a lit strip: eleven rough (0.6) dielectric patches across U whose linear albedo steps in equal ratios from 0.04 (the darkest real dielectric, charcoal) to 0.9 (fresh snow), passing 0.19, close to middle grey, in the centre patch. Every surface in a physically based scene should fall inside this strip; set exposure so the ends separate from black and white. Drawn by the procedural `calibStepRamp` shader (channel 2, geometric spacing), no textures; the capture tool's albedo pass shows the stepped values. Part of the materialLibraryKit calibration shelf.
# blackCard
A lit PBR black reference card: a rough (0.6), non-metallic dielectric with linear albedo 0.04, the darkest albedo a real dielectric has (charcoal, black velvet sit near it). Shadows and fill light should still separate it from pure black; if it crushes to zero, the shadows or the tone curve lose detail. Its specular highlight still reflects 4% at normal incidence, as every dielectric does. Drawn by `@builtin::shaders.pbr`, no textures. Part of the materialLibraryKit calibration shelf.
# Glossy black ball
A glossy black dielectric: linear albedo 0.04 (the darkest real dielectric),
roughness 0.05, not metallic. With almost no diffuse, it shows the pure
specular reflection of the sky and each light, the reference for judging
reflections on dark, polished surfaces.
# Emitter (1.0)
A flat emitter at a plain 1.0 reference brightness: white emission at
intensity 1 on a black base. Next to the 16x emitter it shows where the
exposure puts a 1.0 emission and when bloom starts.
# greyGlossy
A glossy 18% grey dielectric (linear albedo 0.18, roughness 0.1, metallic 0): the same diffuse response as the matte grey card but with a tight, bright specular lobe, so on a sphere it shows the shape, size and position of every light's highlight and the reflected environment over a known middle-grey base. Drawn by `@builtin::shaders.pbr`, no textures. Part of the materialLibraryKit calibration shelf.
# Matte 18% grey ball
A matte diffuse ball: 18% grey, roughness 1, no specular reflectance. It
shows the light's diffuse falloff and the shadow terminator without any
highlight, the reference for judging key-to-fill ratios.
# metallicRamp
A metallic ramp on a light grey (linear 0.8) base at roughness 0.25: eleven discrete patches across U step metallic from 0.0 (a white dielectric) on the left to 1.0 (a bright silver metal) on the right in 0.1 increments, each framed by a thin dark separator. It shows the diffuse term fading and the tinted specular taking over, and reveals the in-between values that real materials should avoid. Drawn by the procedural `calibStepRamp` shader (channel 1), no textures; the capture tool's metallic pass shows the eleven stepped values. Part of the materialLibraryKit calibration shelf.
# normalTest
A normal-mapping test pattern on flat mid grey (linear 0.18, roughness 0.5): a 4 x 3 grid of hemispheres alternating raised bumps and sunken dents (the top-left cell is a bump), and below them a raised ridge with 45-degree bevels running along U and a sunken bevelled groove. The geometry stays flat; only the shading normal is perturbed, so a bump that lights on the light's side and darkens on the far side proves the tangent frame and the normal-map convention are right, and a bump that lights like a dent shows they are flipped. Drawn by the procedural `calibNormalTest` shader, no textures; the capture tool's normal pass shows the perturbed normals. Part of the materialLibraryKit calibration shelf.
# roughnessRamp
A roughness ramp on a mid-grey (linear 0.18) dielectric: eleven discrete patches across U step perceptual roughness from 0.0 on the left to 1.0 on the right in 0.1 increments, each framed by a thin dark separator. Lit, it shows how the specular highlight spreads and dims as roughness rises, and whether the renderer's roughness response is smooth and monotonic. Drawn by the procedural `calibStepRamp` shader (channel 0), no textures; the capture tool's roughness pass shows the eleven stepped values. Part of the materialLibraryKit calibration shelf.
# uvChecker
An orientation-readable UV checker: an 8 x 8 checker whose cells grow redder as U increases and greener as V increases, a white-ringed blue disc in the (0,0) corner cell, a red arrow pointing +U and a green arrow pointing +V from that corner, and a large outlined white "F" in the middle. Laid out like an image texture (v = 0 at the top), so on a correctly mapped mesh the F reads upright with the marker at its top-left, and a mirrored, flipped or rotated UV layout is obvious at a glance; the checker also shows stretching and texel density. Drawn by the procedural `calibUvChecker` shader (lit dielectric, roughness 0.6), no textures. Part of the materialLibraryKit calibration shelf.
# whiteCard
A lit PBR diffuse-white reference card: a rough (0.6), non-metallic dielectric with linear albedo 0.9, about the brightest physically plausible diffuse surface (fresh snow, white paper). Under a correctly exposed key it should sit just below clipping while the 18% grey card reads as middle grey; if it blows out, the exposure or the light is too hot. Drawn by `@builtin::shaders.pbr`, no textures. Part of the materialLibraryKit calibration shelf.
# calibNormalTest
A procedural normal-mapping test pattern for the materialLibraryKit calibration shelf. The surface stays geometrically flat while the shader writes an analytic, perturbed shading normal: the top three quarters hold a 4 x 3 grid of true hemispheres alternating raised bumps and sunken dents (the (0,0) top-left cell is a bump), and the bottom quarter holds a raised ridge with 45-degree bevels running along U and a sunken bevelled groove below it, all on mid grey (`base_color`, `roughness`). A bump must light on the side facing the light and darken on the far side, a dent the reverse; if they swap, the tangent frame or the green-channel convention is wrong (the green channel follows the OpenGL / glTF convention the engine uses, +Y = image up; `flip_y = 1` gives the DirectX convention to compare). `strength` scales every tilt. Engine-lit `surface()` body with no textures, so the `normal` capture pass shows the perturbed normal per pixel. Used by the `normalTest` material.
# calibStepRamp
A procedural calibration ramp for the materialLibraryKit calibration shelf: the surface is cut into `steps` discrete patches across U, and each patch sets one PBR channel — roughness (`channel = 0`), metallic (`channel = 1`) or albedo (`channel = 2`) — to a value stepped from `low` (left) to `high` (right), linearly or, with `geometric = 1`, in equal ratios. Thin dark dielectric separators (`separator_width`, `separator_color`) frame every patch so the steps read under any light. The channels that are not ramped take `base_color`, `roughness` and `metallic`. It is an engine-lit `surface()` shader with no textures, so the capture tool's roughness, metallic and albedo passes show exactly the stepped values each patch carries. Used by `roughnessRamp`, `metallicRamp` and `albedoRange`.
# calibUvChecker
A procedural, orientation-readable UV checker for the materialLibraryKit calibration shelf. It draws a `cells`-per-unit checker whose light cells carry a red gradient rising with U and a green gradient rising with V (dark cells are the same colour at `dark` brightness), thin black grid lines, a white-ringed blue disc in the (0,0) cell, a red arrow pointing +U and a green arrow pointing +V out of that corner, and a large outlined white "F" in the middle. It is laid out like an image texture (v = 0 is the top row), so on a correctly mapped mesh the F reads upright with the marker at its top-left; a mirrored, flipped, swapped or rotated UV layout shows immediately as a backwards or upside-down F and arrows pointing the wrong way. Lit dielectric (`roughness`), textureless, engine-lit `surface()` body. Used by the `uvChecker` material.
# materialLibraryCore
Tests the Materials library and the materialCollection type: version in step
with package.yaml and the README, tag completeness, every list filter, get,
categories, styles, collections, problems, the calibration shelf, findMaterial,
measureTextures, apply / applied / clear, hierarchies, keyOf / reapply,
onChanged, and collection parsing with defaults and bad tags. Uses temporary
entities only. Run: `asset.resolve("materialLibraryCore", "testSuite"):run()`.
# materialLibraryShared
Tests the shared state: Sync newest-wins merging (older, newer and tied
stamps), a remote material assignment reaching its entity, every Gallery call
and exhibit, and every Lighting call and preset. Leaves the gallery cleared and
lighting on daylight. Run: `asset.resolve("materialLibraryShared", "testSuite"):run()`.
# materialLibraryTools
Tests the picker (every style, target, pick, onPicked, visible), the toolbar
actions, and the Checks calls (views, seams, shimmer, textureMemory, budget,
run, assertGood), including that no probe entity is left behind. Run:
`asset.resolve("materialLibraryTools", "testSuite"):run()`.
# 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`.