# 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`.