# styleKit
Build your own art style out of stackable look blocks: a look is a stack of small blocks (a wash, an ink line, a paper, a grain) that runs over the whole frame after antialiasing, lives in the scene and looks the same for every player.
Version 1.2.3.
## Install and use
Install **styleKit** from ZeroMind (search "styleKit" or "stackable art style blocks"). Installs with several dependencies can be slow (#21420), so give an install time to finish and check `/zero/source` for the package folder. Then, from the console or any script, stack one of each of the kit's four example blocks:
```luau
local StyleKit = require("~styleKit.StyleKit")
StyleKit.add("posterize") -- Colour: a few flat tone steps
StyleKit.add("duotone", { strength = 0.5 }) -- Colour: two inks, laid half over the posterize
StyleKit.add("depthLine") -- Line: a clean ink outline from depth
StyleKit.add("sketchLine", { strength = 0.6 }) -- Line: soft pencil strokes laid over the outline
print(#StyleKit.describe().entries) -- 4
StyleKit.clear() -- the frame is exactly as rendered again
```
No material is edited. The first `add` makes a synced `StyleStack` entity in the scene, so every player draws the same stack; put a `StyleStack` component in your scene's build to keep a look with the scene.
## The blocks in this package
| Block | Slot | What it does |
|---|---|---|
| `posterize` | Colour | Cuts the picture into a few flat tone steps, like a screen print. |
| `duotone` | Colour | Maps the picture onto a shadow ink and a light ink. |
| `depthLine` | Line | A clean, one-sided ink outline from depth plus creases from normals. |
| `sketchLine` | Line | Soft, broken pencil strokes over shape and colour edges, redrawn on the held clock. |
| `sharedView` | Medium | A diagnostic for block authors: shows one shared input in place of the picture. |
Every block lists its slot and its tags (the words a person would search for: `watercolor`, `pencil`, `halftone`, `pixel art`, `chalk`), so you can pick blocks by tag with `StyleKit.blocks({ tag = "pencil" })` or `zero styleKit list --tag pencil`. A block's tags come from its `block.yaml` `tags:` list when it has one, otherwise from its `.metadata` tags.
More blocks come in their own packages and plug straight in: `starterBlocks` has a block for every slot (card surface, light bands, flat wash, surface dots, plain paper, dark board, line boil, chunky pixels, soft grain, vignette), `watercolorBlocks` has ten chained watercolor Fill blocks and a cold-press paper, with pigment sets, whole looks and a hero material, `inkBlocks` has five ink lines, four ink fills, ink colours and whole looks, and `paperBlocks` has seventeen papers (cold press, rough, hot press, rice, newsprint, kraft, parchment, canvas, board, blackboard, green chalkboard, black paper, aged sketchbook, toned grey and tan, ruled notebook, vellum) that publish the paper field and layer by region. `StyleKit.blocks()` lists every block installed. A package can also ship pigment sets (`pigmentSet` assets: `StyleKit.pigmentSets()`) and whole looks (`styleLook` assets: `StyleKit.looks()`) next to its blocks.
## How a stack works
A stack is an ordered list of entries `{ id, block, on, strength, mask, settings }` on a `StyleStack` component. Blocks sit in ten slots that always run in this order:
1. **Surface**: what things are made of.
2. **Light**: how surfaces take light.
3. **Fill**: how areas of colour are laid.
4. **Tone**: marks that carry shade.
5. **Line**: edges.
6. **Colour**: palette and grade.
7. **Ground**: what it is drawn on. The first Ground block publishes the shared ground (and the paper field, when it has one); a later Ground block whose field says `layers: true` lays its ground over the one before by its strength and mask.
8. **Time**: how motion is held. The first Time block publishes the shared wobble.
9. **Raster**: how the image is sampled.
10. **Medium**: what it is seen through.
Any slot can be empty. Any block swaps for any other block of its slot with no other change (`StyleKit.swap(entry, otherBlock)`); strength and mask stay. Inside a slot the list order decides: the first block reads the slot's input, and every later block in the same slot reads the same input and is laid over it by its strength and mask (`stacking: layer`, the default). So two Line blocks with weights 1 and 0.4 blend 60 / 40, and two Fill blocks with complementary masks split the frame. A block whose `block.yaml` says `stacking: chain` reads what the blocks before it in its slot made instead, so modifier blocks build on each other (a wash, then its granulation, then its darkened edges). A block's `rank: <number>` decides where `StyleKit.add` places it: before the first block of its slot with a higher rank, so a chain lands in its painting order whatever order its blocks are added in.
### Keeping a stack
A stack made with these calls is a live edit of the scene, like moving a prop. Save the scene (`layers.active:save()` or the editor's save) and the stack is in its `scene.json`: it comes back on every load and after a restart, the same for every player. `StyleKit.clear()` takes every block off the frame; to get the saved stack back, revert the scene (`tools.use("scene", "revert")`). `scene.reload` keeps live edits by saving them first, so a clear followed by a reload is a saved clear. Keep one StyleStack per scene.
### Shared by every block in a stack
- **One clock.** `sk_time()` and the held clock `sk_held()` / `sk_step()`, stepped at the stack's `holdRate` (8 per second by default). Every wobble and boil runs on the held clock.
- **One set of coordinates that stay on things.** `sk_surface_coord(uv, perMetre)` lays marks on surfaces from world position along each surface's main axis, and on the sky from the view direction, so a pan or a dolly does not drag them.
- **Surface data copied once per frame**: view distance, world position, normal and base colour (`sk_dist`, `sk_world`, `sk_normal`, `sk_albedo`).
- **One ground** (`sk_ground_at`): paper colour and grain height, published by the first Ground block (plain white paper with flat grain when there is none).
- **Layered grounds** (since 1.2.0): a later Ground block whose `block.yaml` field says `layers: true` lays its ground, and its paper field, over the one before by its strength and mask, so regions of a world sit on different papers: `StyleKit.add("paperParchment", { id = "mapRoom", mask = { kind = "sphere", center = { 30, 2, 10 }, radius = 6 } })`. A Ground block without `layers: true` publishes only when it is the first.
- **One paper field** (`sk_paper`, read through `skPaper`, since 1.2.0): how the sheet under each pixel takes paint and ink, for a block that lists `paper` under `reads`: `sk_thirst(uv)` (-1 hard-sized, 0 the standard sheet blocks are tuned on, +1 thirsty), `sk_sheen(uv)`, `sk_fibre(uv)` and `sk_dark_ground(uv)` (1 where the sheet is dark and the picture is made of light marks). A Ground block publishes it with a `field.paper` shader; where none does every value is 0, so a block reading it draws exactly as before. It is only made when some live block reads it. paperBlocks' papers publish it; watercolorBlocks' bleed and edge darkening and every inkBlocks block read it.
- **The scene's sun** (`sk_sun`, a float4, since 1.2.0): for a block that declares it and lists `sun` under `reads`, the direction toward the scene's first directional light, `w` 1 when it gives light (0, and a default direction, when there is none). Read about once a second through the lighting toolbox, so a moving sun reaches blocks within a second. Raking light on paper tooth and sheen use it.
- **One wobble** (`sk_wobble_uv`): an offset published by the first Time block (still when there is none). A wash and an ink line that both read it tremble together.
- **One pattern library** (`skPatterns`): noise, fractal noise, cells, dots, hatching, ordered threshold, triplanar noise for materials (`skp_triplanar_fbm`) and brush strokes (`skp_stroke`).
- **One soft field** (`sk_soft`, read through `skSoft`): a half-resolution, depth-aware blur of what a block reads, for a block that lists `soft` under `reads`. Each tap is tested against the centre surface's plane rebuilt from world positions, so a nearer thing is never smeared over a farther one. The stack makes it for the first block that reads it and makes it again whenever a later reader reads a different image; its width is the stack option `softRadius` (reference pixels, 10 by default).
- **One pigment set** (`sk_pigments`, read through `skPigmentSet`): the scene's palette of paints as a 16 x 1 texture, for a block that lists `pigments` under `reads`. `StyleKit.setPigments(id)` changes it for every block and every player at once; with none picked blocks keep the scene's own colours.
- **One rendered frame** (`sk_rendered`, read through `skRendered`): the frame as it entered the stack, for a block that lists `rendered` under `reads`. Light estimates read it against the base colour, so surface texture cancels instead of printing back as shade. With `sk_geo_normal` (a normal rebuilt from world positions, every tier) it lets a block judge light and shape on the low tier and the forward path too.
- **One way to mix paint** (`skPigment`): pigment mixing in transmittance and optical density (log space), two pigments in one wash, glazes that multiply, dilution toward the paper. Pure math, so a full-frame block and a material mix paint the same way.
- **Noise that stays on things and keeps its size** (`skAnchored`): triplanar noise on world position that stays on still things (`sk_anchored_fbm`, `sk_anchored_cells`), and noise that also keeps its size in the picture at any distance by blending two octaves by distance (`sk_scaled_fbm`, `sk_scaled_level`, `sk_surface_plane`, `sk_ref_metres`), for paper tooth, flecks and strokes. `sk_tooth(uv)` reads the shared paper's valleys (0 on a peak, 1 in a valley).
- **Chain stacking** inside a slot (`stacking: chain` and `rank` in `block.yaml`): later blocks build on what the ones before them made.
- **Many blocks, one shader** (`defaults:` in `block.yaml`, since 1.2.0): a block's own defaults over its shader's, so a family of blocks (paperBlocks' seventeen papers) shares one shader and each block is its own starting point. `StyleKit.settingsOf(id)` reports the block's defaults.
- **Swap memory** (since 1.2.0): each stack entry remembers the settings of every block it has held, so `StyleKit.swap("paper", "paperKraft")` then `StyleKit.swap("paper", "inkPaper")` puts back exactly the settings `inkPaper` had, and the frame returns exactly. The memory is kept on the entry in the synced stack, so it is shared and saved with the scene; `load` and `applyLook` write new entries without it.
- **One exit** (`sk_finish`): strength, mask, layer mode, and black stays black: a block never makes light where the frame had none.
### Masks
`StyleKit.setMask(entry, mask)` limits a block:
- `{ kind = "depth", near = 0, far = 12, soft = 0.2 }`: by view distance in metres. The soft edges lie outside the band and scale with their own distance: the block fades in over `soft` x `near` metres before `near` and out over `soft` x `far` metres after `far`, so nothing nearer than `near` x (1 - `soft`) is touched, whatever `far` is. The sky lies at the viewing camera's far distance (its `Camera.far`, 8000 m by default): a band whose `far` reaches that distance takes the sky in, a shorter one leaves it out.
- `{ kind = "rect", rect = { x0, y0, x1, y1 } }`: a screen region in 0..1 (top left is 0, 0).
- `{ kind = "sphere", center = { x, y, z }, radius = 5 }`: a region of the world.
- `{ kind = "group", group = 1 }`: the objects in mask group 1, 2 or 3 (`StyleKit.options({ groups = { { id, id }, { id } } })`).
- `invert = true` flips any of them.
`StyleKit.options({ exclude = { "SignBoard" } })` leaves objects (and their children) exactly as rendered: a prop, a world label. Name them: an entry is an entity name or an id, and a scene should store names, because an id written into the stack does not survive the scene being saved and loaded again (the entity comes back under a new id). Entities that arrive later are picked up within half a second. Put labels on a card of their own (a plain white quad behind the words), so the area left out around them is card rather than the raw scene.
### Quality
The stack follows the device's quality tier (`renderer.qualityTier()`), and the tiers really change the cost:
- `high` and `medium`: every block pass and every shared input at full resolution. `medium` also applies each block's `quality.medium` settings where it states them.
- `low`: the shared inputs (surface data, ground, wobble) and the block chain itself run at half resolution, a quarter of the pixels for every pass, and blocks take their `quality.low` settings (fewer samples, fewer strokes). The copy of base colour and normals is made on `low` too, at that half resolution (since 1.1.4), so a block judges light and lays marks by the same rules on every tier and a look stays the same look; only the forward render path, which keeps no g-buffer, goes without it. The result is scaled up once at the end, before the UI; excluded objects are put back from the full-size frame, so they stay crisp. Measured on the Lookbook's textured wall at 2097 by 1409 with a wash, a sketch line, paper, boil and dots: 1.64 ms of block passes at full resolution, 0.30 ms on `low` (before the half-size copy, which adds one small compute pass).
On `low`, marks are drawn at half resolution and so are softer: the honest trade for running on a small device. `StyleKit.setQuality("low")` forces a tier on this machine; `StyleKit.setQuality(nil)` follows the device again. The Lookbook measures the cost per pass at each tier.
### Whole frame or inside a material
Every block works on the whole frame with no material edits. Where it means something, a block also ships a shader module a material includes for a chosen set of objects (`include:` in its `block.yaml`), for example starterBlocks' Stepped light, whose `bandedToon` shader bands a material by the same rule. Use a group mask to keep the full-frame pass off those objects.
## API
| Call | What it does |
|---|---|
| `StyleKit.blocks(filter?)` | Every block installed: `{ id, name, slot, tags, summary, reads, writes, settings, package, path }`. Filter by `slot`, `package`, `tag` (one tag or a list, all must match) or `text` (which also searches tags). In the shell: `zero styleKit list`, `zero styleKit list line`, `zero styleKit list --tag pencil`. |
| `StyleKit.block(id)` / `settingsOf(id)` | One block / its settings with docs, defaults (the block's own `defaults:` where it states them) and ranges. |
| `StyleKit.add(id, opts?)` | Add a block (`strength`, `mask`, `settings`, `id`, `index`). Returns `ok, entryId`. |
| `StyleKit.remove(entry)` | Remove an entry (by entry id, block id or position). |
| `StyleKit.swap(entry, id, opts?)` | Swap an entry's block for another of its slot; strength and mask stay. The entry remembers the settings of every block it held, so a block swapped back in gets exactly the settings it had (a block it never held starts from its defaults). `{ keepSettings = true }` carries the current settings across instead; `{ anySlot = true }` allows a swap across slots. |
| `StyleKit.move(entry, index)` | Reorder (orders blocks inside a slot). |
| `StyleKit.setStrength(entry, 0..1)` / `setOn(entry, bool)` | Weight / switch an entry. |
| `StyleKit.setMask(entry, mask?)` | Limit an entry by depth, region or group. |
| `StyleKit.set(entry, { name = value })` | Change settings. Colours take `"#rrggbb"`, `{ r, g, b }` or `{ r, g, b, a }`. |
| `StyleKit.load(entries, opts?)` / `clear()` | Replace / empty the stack. `opts`: `holdRate`, `markScale`, `pigments`, `softRadius`, `look`. |
| `StyleKit.options({ holdRate, markScale, exclude, groups, active, pigments, softRadius })` | Stack-wide options. `pigments` is a pigment set id (`""` for none); `softRadius` is the soft field's width in reference pixels (2 to 40, default 10). |
| `StyleKit.pigmentSets(filter?)` / `pigmentSet(id)` | Every pigment set installed: `{ id, name, summary, tags, pigments = { { name, colour, granulation } }, shadow, light, air, package, path }`, filtered by `package`, `tag` or `text` / one set by id. |
| `StyleKit.setPigments(id)` | Pick the pigment set every block that reads pigments mixes from, for the whole stack and every player. `""` picks none, so blocks keep the scene's own colours. |
| `StyleKit.looks(filter?)` / `look(id)` | Every whole look installed (`styleLook` assets): `{ id, name, summary, tags, entries, pigments, holdRate, markScale, package, path }` / one look by id. |
| `StyleKit.applyLook(id, opts?)` | Apply a whole look in one change: its blocks replace the stack and its pigment set, hold rate, mark scale and soft radius are set with them. `{ keep = { "line" } }` keeps the entries of the slots named from the current stack. A look naming a block that is not installed is refused and nothing changes. |
| `StyleKit.describe()` | What is in the stack, in run order, its `pigments`, `look` and `softRadius`, and what the runtime drew last frame. |
| `StyleKit.checkBlock(id)` | Problems with a block against the block rules (empty when it passes). |
| `StyleKit.newBlock(name, { slot, folder })` | Write a working block of your own to start from. |
| `StyleKit.quality()` / `setQuality(tier?)` | The tier the stack draws at / force one here. |
| `StyleKit.setClock(fn?)` | Drive the stack's one clock (boil, grain, wobble) from your own time in seconds, for a cutscene, a recording or the Lookbook; `nil` returns to the shared clock (wall-clock UTC, the same held step on every player's machine). |
Writes go through the stack's owner, so in play any player can change the shared stack and every player sees the change. The `styleKit` toolbox offers the same from the shell (`zero styleKit list`, `zero styleKit stack`, `zero styleKit add posterize`).
## Writing a block
A block is a plain folder holding `<name>.styleBlock/` (block.yaml, README.md, .metadata) and its `<name>.shader/` (a post-process shader whose `properties.yaml` starts with the styleKit shared header). `newBlock` writes one that works and registers it at once:
```luau
local StyleKit = require("~styleKit.StyleKit")
local path = StyleKit.newBlock("warmTint", { slot = "medium", title = "Warm tint" })
print(path) -- /zero/source/myBlocks/warmTint/warmTint.styleBlock
StyleKit.add("warmTint", { strength = 0.6 }) -- a warm filter over the whole look
StyleKit.set("warmTint1", { tint = "#ffcf8a" }) -- its setting, like any block's
```
Then edit `/zero/source/myBlocks/warmTint/warmTint.shader/shader.wgsl` to make it do what you want; the stack picks up the change as you save. To share blocks, move their folders into the `blocks/` folder of a package of your own (a `<name>.package` folder with its `package.yaml`): a package that holds blocks adds them to every world that installs it, with no code. The guide "Writing your own style block" walks the whole shape every block follows.
## Rules every block follows
- It states its slot, what it reads and what it writes, and takes over nothing it does not own (a Ground block alone writes the ground, a Time block alone the wobble).
- Every setting is named, documented in `block.yaml` and has a good default in its shader.
- Marks stay on things unless the medium really is a screen (pixels, grain, a vignette), and they run after antialiasing and upscaling.
- Wobble runs on the held clock.
- Its README says what it gives up in motion and how it treats opaque things, transparent things, particles, sky, fog, skinned characters, foliage and world text.
- With every light off the frame stays black (`sk_finish` holds every block to it).
- The UI is drawn after the stack, so it stays readable.
- No full-screen flashing above 3 Hz and no fine high-contrast stripes filling the view.
`StyleKit.checkBlock` and the `styleKit` test suite check the rules that can be read from files.
## Guides and workflow
This package carries its guides (search them with the `guides` tool or ZeroMind): combining blocks into your own art style, how to use styleKit, writing your own style block, and a worked example from an empty world to a small stylized game. The `restyleWorld` workflow gives an existing world, scene or kit a new look non-destructively, and the `styleKit` agent skill tells an agent what it can do with blocks.
## What a look costs
GPU milliseconds per frame of the style passes, 3D view drawing at 1893 x 1072 (98% of a 1920 x 1080 frame's pixels), measured with the Lookbook (`zero lookbook cost 1920 1080 true`) over its pan on a desktop GPU. The rest of the frame (scene, shadows, antialiasing) is not counted. Low quality runs the whole chain at half resolution.
| Look | High | Low |
|---|---|---|
| README example: posterize, duotone 0.5, depthLine, sketchLine 0.6 | 0.60 | 0.11 |
| Cookbook: flatWash, depthLine, plainPaper, lineBoil, softGrain 0.6 | 1.03 | 0.21 |
| Comic: lightBands, surfaceDots, depthLine, posterize | 0.41 | 0.10 |
| Chalk: depthLine, darkBoard | 0.43 | 0.10 |
| Retro: posterize, chunkyPixels, softGrain, vignette | 0.22 | 0.07 |
Each block alone, high / low: flatWash 0.45 / 0.07, sketchLine 0.32 / 0.06, darkBoard 0.24 / 0.07, plainPaper 0.23 / 0.07, depthLine 0.19 / 0.04, cardSurface 0.11 / 0.03, lightBands 0.09 / 0.03, surfaceDots 0.09 / 0.03, softGrain 0.09 / 0.03, lineBoil 0.06 / 0.02, chunkyPixels 0.06 / 0.02, posterize 0.05 / 0.02, duotone 0.04 / 0.02, vignette 0.04 / 0.02. A look costs a little less than its blocks added up, because the shared inputs (depth, surface coordinates, ground) are made once per frame for the whole stack.
**Frame rate and the web.** On the web the Cookbook look was measured at about 29 to 30 fps in the Cookbook scene (one sample 25), against about 43 fps with no look, and the low tier did not change it: the look cost about 10 ms a frame there while its passes took about 1 ms of GPU time. That measurement is of styleKit 1.0.6. The cost was not the shading but the work around it, which a browser pays several times over: 15 screen-sized images made up front whatever the look, 11 passes, and engine calls every frame for every effect. 1.0.7 cuts it, measured on desktop: the Cookbook look now makes 7 images on high and 5 on low (was 15), runs 9 passes on high and 8 on low (was 11), and spends 0.19 ms of Luau a frame on high and 0.12 ms on low (was 0.40 ms on both), so low now saves passes and Luau, not only pixels. **Whether 1.0.7 holds 30 fps on the web is not measured yet:** an agent cannot start a browser engine (#20946), and in the desktop editor the whole-frame rate swings more from the editor's own load than the look costs, so no desktop fps is quoted here. `zero lookbook report` now starts with the frame rate in the scene you are in (no look, the look on high, on low, measured in turns), so one run in the web build gives the answer. The web limits it may still meet are open engine issues: #20750 (web frame rate), #17441 (cost of scene-colour features) and #17478 (post tiers on mobile).
## How it runs
The `styleStack` render feature starts on a machine as soon as the scene's stack holds a live block (one that is on, has strength above 0 and is installed), on every player's machine alike. Each frame it copies the base colour and normal from the deferred g-buffer at `afterLighting`, then at `afterScenePost` (after antialiasing and the render-scale upscale, before the UI) it rebuilds world position and distance from depth, copies the frame, publishes the ground and wobble, runs each slot, puts excluded objects back and hands the result to the screen. A frame of the Cookbook look runs 9 passes: the copy of base colour and normals (only when a live block reads them, and never on the low tier), world position, the two shared fields, one pass per block, and the hand-off to the screen; the blocks read the scene directly, so there is no copy of the frame first. Only five screen-sized images are made up front (the chain's two, world position and the two fields); the rest are made the first time a look uses them (group masks, excluded objects, the base colour and normal copies). The Luau work per frame is a few calls into the engine: uniforms, settings and mask groups are written only when the stack, the tier or the camera's frame inputs change, and the clock only when the held step moves. When the stack holds no live block (after `StyleKit.clear()`, or with every entry off) the kit lets everything of its own go within two frames: every block effect and its five internal effects are removed from the screen effect list, its screen targets, buffer and mask materials are freed, and the render feature itself ends. `StyleKit.holdsAnything()` then reads false, and the first block added brings it all back.
## Known limits
- **Offscreen captures and render-to-texture cameras do not draw the stack.** The stack runs at `afterScenePost`, which only the presented frame reaches, so `capture` with `source = "main"`, `"entity"` or `"position"` shows the unstyled scene; use `source = "presented"` or `renderer.presentedFrames`. Per-camera post: engine issues #66 and #17427.
- **Depth in a feature's fragment pass reads 0 after post.** `zero_scene_depth` is not bound to a render feature's fragment pass at `afterScenePost`, so the kit copies depth with a compute pass once per frame and blocks read that copy (#17427).
- **G-buffer channels are refused after post**, so base colour and normal are copied at `afterLighting`. On the forward path there is no base colour: Surface and Light blocks fall back to working from brightness alone.
- **Marks on moving objects.** Surface coordinates come from world position, so marks stay put on still things under any camera move, but slide across a moving skinned character or a moving prop. Object-space coordinates need per-object data a full-frame pass does not have, so marks that stay on a moving object can only be drawn inside its material: watercolorBlocks' `watercolorSurface` shader shows how, carrying the object's own position from `vertex_clip` (`local_position`) to the fragment in the vertex colour (applied with `WatercolorHero.paint`).
- **The soft field costs two half-resolution passes each time a reader reads a new image.** In a chain, a later block that reads `soft` reads a different picture from the first one, so the field is made again for it; keep soft readers few, or next to each other on the same input, on small devices.
- **Post effects get one pass each and no history** (#17427): a block cannot read the previous frame or its own last output, so nothing in a block can build up or settle over time.
- **Effect uniforms reset on recompile** without notice; the runtime renews its writes twice a second, so after a shader edit a setting can take up to half a second to come back.
- **One held clock for every player, as close as their clocks are set.** Since 1.1.3 the held clock runs on wall-clock UTC (caught to the frame from `os.time()` ticking over), so every machine shows the same boil step at the same moment; machines whose clocks are set by the network agree to a few hundredths of a second, so two screens differ only in that sliver around each step. A machine whose clock is set wrong boils out of step with the others. `StyleKit.setClock` drives it from a time of your choosing instead, so a cutscene or the Lookbook repeats exactly.
- **Transparent things and particles have no depth**, so lines and surface marks treat them as the opaque surface behind them; each block's README says how.
- **Stage order** (#17426, #17439): blocks rely on `afterScenePost` running after the TAA resolve and the upscale, as the engine documents today.
- **One stack per scene.** With two StyleStack entities in a scene the kit draws the last one it found; keep one, and change it rather than adding a second.
- **After a scene switch** the engine can free the stack's screen targets; the runtime notices within about two seconds and rebuilds them, so the look can be missing for that moment.
- Post-process shaders have no preview image, so the shader previews in this package are absent.
## Version history
**1.2.3 (2026-11-02)**: the scene's sun is written to every block that reads it again twice a second, like every other shared value, so a block whose shader was compiled or recompiled after its sun was written (the first use of a block in a fresh install, a hot reload) no longer draws without its sun until the sun moves. This was why paperBlocks' Lookbook numbers changed from run to run. And outline ink: a surface that publishes metallic 3/255 with roughness 251/255 (toonBlocks' outlines) is copied with base colour known 0.5, `sk_is_ink(uv)` answers it, and every block, which takes base colour only above 0.5, leaves drawn ink as drawn. `StyleKit.VERSION` says 1.2.3.
**1.2.2 (2026-11-01)**: the scene's sun (`reads: sun`, `sk_sun`) is read every 4 frames instead of about once a second, at once whenever the stack changes, and every frame while someone drives the clock (`StyleKit.setClock`, as the Lookbook does). A block that rakes light from the sun (paperBlocks' papers) no longer steps once a second under a moving sun, and no longer draws its first second with the sun of the scene or run before it, which had made Lookbook runs depend on what ran before them. `StyleKit.VERSION` says 1.2.2 (it had stayed at 1.2.0).
**1.2.1 (2026-11-01)**: documentation only: install notes describe how installing works (install from ZeroMind search; installs with several dependencies can be slow) without naming internal ticket numbers.
**1.2.0 (2026-11-01)**: the paper field, a second shared ground input (`reads: paper`, `sk_paper`, read through `skPaper`: thirst, sheen, fibre, dark ground), published by a Ground block's `field.paper` shader, neutral where none does and made only when a live block reads it; layered grounds (`field.layers: true`: a later Ground block lays its ground and paper field over the one before by its strength and mask, so regions of a world sit on different papers); the scene's sun for blocks that declare `sk_sun` (`reads: sun`); `defaults:` in `block.yaml` (per-block defaults over the shader's, reported by `settingsOf`, so many blocks share one shader); swap memory (each entry remembers the settings of every block it held, so swapping back returns the frame exactly; `keepSettings` carries settings across instead). `checkBlock` knows `field:paper` (Ground only), `sk_paper` and `sk_sun`, and a default for a setting the shader lacks.
**1.1.0 (2026-10-31)**: blocks can build on each other inside a slot (`stacking: chain` in `block.yaml`; `layer` stays the default) and take a place in it (`rank`); two extra inputs a block may declare after the shared header, the soft field (`sk_soft`, a half-resolution, depth-aware blur, width `softRadius`, read through `skSoft`) and the scene's pigment set (`sk_pigments`, read through `skPigmentSet`); pigment mixing in optical density (`skPigment`), noise that stays on things and keeps its size in the picture (`skAnchored`), `skp_triplanar_fbm`, `skp_stroke` and `sk_tooth`; two new asset types, `pigmentSet` and `styleLook`, with `pigmentSets`, `pigmentSet`, `setPigments`, `looks`, `look` and `applyLook`; `options` and `load` take `pigments` and `softRadius`, `describe()` returns `pigments`, `look` and `softRadius`, and the StyleStack carries them as synced fields.
**1.1.5 (2026-11-01)**: documentation only: Known limits and the using-styleKit guide said the held clock was each machine's own and out of step between players; since 1.1.3 it runs on wall-clock UTC and every machine shows the same boil step. Both now say so.
**1.1.4 (2026-11-01)**: the `low` tier makes the copy of base colour and normals too, at its half resolution, so a block judges light and lays marks by the same rules on every tier (a look on `low` had stopped being the same look: light read from brightness turned dark materials grey and put mid-tone marks on lit walls). `sk_mark_normal(uv)` gives marks a surface facing on every path, rebuilt from neighbouring world positions where no normal was copied, and `sk_surface_coord`, `sk_surface_plane`, the triplanar noise and `sk_ref_metres` use it, so a wall is never projected as a floor (which smeared marks down their columns into vertical stripes).
**1.1.3 (2026-11-01)**: the stack's held clock runs on wall-clock UTC (caught to the frame from `os.time()` ticking over), so every player's machine holds the same boil step at the same moment instead of each running its own; the step number wraps every 65536 steps at the same moment everywhere, so shaders keep every digit. `StyleKit.setClock(fn)` still drives it for a cutscene or the Lookbook.
**1.1.2 (2026-11-01)**: `exclude` and group lists take entity names as well as ids (an id stored in a scene does not survive a save and load, so a scene's left-out board or labels were being painted over), and entries that find nothing yet are looked for again twice a second; a stack whose component was removed and added again is let go instead of refusing every change.
**1.1.1 (2026-10-31)**: a third extra input, the frame as rendered (`reads: rendered`, `sk_rendered`, `skRendered`), for light estimates that do not print surface texture back; `sk_geo_normal` (a normal from world positions on every tier); and `sk_albedo` and `sk_normal` answer "unknown" when no g-buffer copy was made (low tier, forward path) instead of reading a stale frame.
**1.0.8 (2026-10-31)**: store listing rewritten for creators; built on the current engine library (@builtin 0405ea4).
**1.0.7 (2026-10-31)**: a depth mask's near limit holds whatever `far` is (each soft edge now scales with its own distance, not the whole band) and the sky lies at the camera's far distance; blocks list their tags and `blocks({ tag })` and `zero styleKit list --tag` pick by them; a lighter frame for the web: images made only when a look uses them (7 on high, 5 on low, was 15), no copy of the frame before the chain, no base colour copy on low or when no block reads it, and uniforms, settings and the clock written only when they change (Luau per frame 0.19 ms high, 0.12 ms low, was 0.40).
**1.0.6 (2026-10-31)**: clearing the stack leaves nothing of the kit behind (every effect, target, buffer and the render feature are released within two frames, and `StyleKit.holdsAnything()` says so); the renderer starts only for a live block, on every machine; a per-look GPU cost table at 1080p; the wrong "black floor of 17" limit is gone (it was the Lookbook panel); the shared black guard now caps a dark pixel at its own brightness and its own brightest channel, so no block, alone or stacked, lightens an unlit frame (worst of all fourteen: +0.17 of 255).
**1.0.5 (2026-10-31)**: `StyleKit.setClock` drives the stack's clock from a time of your choosing, so boil, grain and wobble repeat exactly in a cutscene, a recording or the Lookbook.
**1.0.4 (2026-10-31)**: `newBlock` registers the block before it returns, so the README example stacks it on the next line; a block whose shader cannot be registered skips itself instead of stopping the whole stack, and the shader index is read afresh when blocks change; the author example writes to `/zero/source/myBlocks`; how to keep, clear and revert a saved stack.
**1.0.3 (2026-10-31)**: screen targets freed by a scene switch are noticed and rebuilt; Known limits on one stack per scene and the dark viewport floor.
**1.0.2 (2026-10-31)**: the `low` quality tier runs the block chain at half resolution (about five times cheaper in the Lookbook), excluded objects are restored at full size on every tier, the black guard closes fully on unlit pixels, the block type README describes the side-by-side folder layout.
**1.0.1 (2026-10-31)**: masks by object group and exclusion drawn per group, durable state across hot reloads, the toolbox, the test suite, `newBlock`, guides, workflow and agent skill.
**1.0.0 (2026-10-31)**: first release. Block type, StyleStack component, runtime, masks (depth, rect, sphere, group), exclusion, shared clock, coordinates, ground, wobble and patterns, quality tiers, four example blocks and a diagnostic block, toolbox, tests, guides, workflow and agent skill.
# lookbookKit
The Lookbook: a scripted test scene with one repeatable 26-second camera path whose stops each test one way a look fails, plus the code that plays it over any style stack, saves the same frames for side-by-side comparison and measures stability with real numbers.
Version 1.0.12. Depends on styleKit 1.2.3 and starterBlocks 1.0.12 (the tests, the combination test and the reference looks use its blocks).
## Install and use
Install **lookbookKit** from ZeroMind (search "lookbookKit" or "style lookbook test scene"); it brings styleKit and starterBlocks along (the Lookbook's tests, combination test and reference looks use starterBlocks' blocks). Installs with several dependencies can be slow (#21420), so give an install time to finish and check `/zero/source` for the package folder. Every call below loads the `Lookbook` scene first when it is not loaded (`Lookbook.ensureScene()`), so you can start from any scene:
```luau
local Lookbook = require("~lookbookKit.Lookbook")
local StyleKit = require("~styleKit.StyleKit")
StyleKit.load({ { block = "posterize" }, { block = "depthLine" } })
local shots = Lookbook.run({ label = "posterInk" }) -- the same frames, as PNGs
local numbers = Lookbook.measure({ dt = 1 / 15 }) -- stability numbers, styled and unstyled
print(numbers.stylized.all.warpedDiff, numbers.stylized.markStuck, numbers.passMs.high.total)
local cmp = Lookbook.compare({ { block = "depthLine" } }, { { block = "sketchLine" } }) -- two stacks side by side
print(cmp.sideBySide[1].file) -- stack A on the left, B on the right
Lookbook.combos("depthLine") -- the combination test
```
From the shell: `zero lookbook measure`, `zero lookbook run myLook`, `zero lookbook combos depthLine`.
## The stops
One camera path, 26 seconds, the same every time (everything that moves is driven by the Lookbook's clock, so the same moment gives the same frame):
| # | Stop | What it tests |
|---|---|---|
| 1 | Textured wall | A fine texture under a pan, a dolly, an orbit and a 10x zoom: marks that slide, swim or keep their screen size. |
| 2 | Flat grey wall under a slow light change | Banding and steps sweeping across a flat surface as light changes slowly. |
| 3 | Thin geometry | Wires and fence slats thinner than a line: shimmer and broken lines. |
| 4 | Floor grazing to the horizon | False lines and mark noise on a surface seen edge-on. |
| 5 | Fast skinned character | Lines and marks on a fast, deforming body. |
| 6 | Contact and intersection | Double outlines and halos where objects touch and cut through each other. |
| 7 | Wind-blown foliage | Busy edges and flicker on swaying leaf cards. |
| 8 | Transparency | Glass, water and smoke over lined objects. |
| 9 | Rotating sun, moving spot, zero lights | Light-dependent blocks under moving light, and a moment with every light off, where the frame must stay black. |
| 10 | Depth planes with fog and sky | Planes from 5 to 160 m in fog: line fading, mark size with distance, sky treatment. |
| 11 | Mixed animation rates | Objects stepping at 6, 12, 24 and 60 per second beside smooth motion and the held clock. |
| 12 | Excluded object, world label and UI | An object the stack must leave alone, a label in the world, and screen UI that must stay readable. |
`LookbookPath.segments()` names the moments the measurements read: `pan`, `dolly`, `orbit`, `zoom`, `slowLight`, `sunRotate`, `zeroLights`, `excluded`. Packages can add stops after the core path with `LookbookPath.extend(stop)` (lookbookShowcaseKit adds one built from published kits).
## The numbers
Everything is measured on the frame the player saw, after antialiasing, the upscale and the stack, before the UI, by the `lookbookProbe` render feature on the GPU, and read back:
- **warpedDiff**: the mean difference (0..255) between each frame and the one before it, warped along the motion vectors, with newly revealed pixels left out (`revealedShare` says how many). A stable look scores close to the unstyled frame; boil and grain raise it on purpose.
- **marks / marksWarped / marksSameSpot / markStuck**: the stack's own marks are the frame the player saw less the frame before the stack. `marks` is how strong their fine detail is; `marksWarped` and `marksSameSpot` correlate it with the previous frame along motion and on the same screen spot. During the pan, **markStuck** = sameSpot less warped: clearly negative when the marks stay on things (plain paper reads about -0.67), clearly positive when they are stuck to the screen (soft grain reads about +0.68). Absent when a stack leaves no marks of its own.
- **detailWarped / detailSameSpot**: how well fine detail (a pixel less the mean of its four neighbours) correlates with the previous frame along motion, and on the same screen spot. During the pan, **screenStuck** = same spot minus warped: positive means marks stay on the screen instead of on things.
- **zoomMarks**: the typical size of fine marks (pixels between detail sign changes) at the start and the end of the 10x zoom. Marks that stay on things grow with the zoom (`growth` well above 1); marks stuck to the screen keep their size (`growth` near 1).
- **passMs**: GPU milliseconds per style pass (`post.sk.<entry>.<block>`) and their total, measured over the pan at each quality tier asked for (high and low by default).
- **baseline**: the same numbers for the unstyled frame, measured in the same run.
- **missed**: frames the probe could not read back (0 in every run so far); a run with misses is not comparable.
How a run steps: the probe itself places the camera and the clock one `dt` step per drawn frame, and measures every frame against the frame before it, so the camera moves on every frame as a player's does and two runs place the same poses and measure the same frames, whatever the frame rate. Two runs of the same stack give identical numbers.
`Lookbook.compare(a, b)` photographs the same moments for both stacks (`<folder>/a`, `<folder>/b`), writes one side-by-side picture per moment (`<folder>/side_<T>_<stop>.png`, A left, B right) and measures both (`numbers.a`, `numbers.b`; pass `numbers = false` to skip).
`Lookbook.combos(blockId)` puts the block with one partner from every other slot (partners at strength 0.6), plays the pan, then swaps the block for a sibling of its slot and plays it again. A round fails when an entry did not run, the engine logged errors, or no frame was measured.
## Cost per look, and measuring on the web
`Lookbook.cost()` (shell: `zero lookbook cost 1920 1080`) times every style pass of a look over the pan, high and low quality, with the 3D view drawing at the size you ask for: in the editor the window is grown until the view itself is that size, so the numbers are for a real 1080p picture. The cost table for this release is in styleKit's README under "What a look costs".
**On the web**, one command does the whole report. Open this world in a browser engine, and in its shell run:
```
zero lookbook report
```
It first measures the frame rate in the scene you are in (`Lookbook.frameRate()`: no look, the Cookbook look on high, on low, taken in turns so a machine that speeds up or slows down meanwhile affects all three alike), then loads the Lookbook scene, measures the Cookbook stack over the full path (stability numbers beside the unstyled frame), prices every reference look at 1920 x 1080, writes `/zero/source/tmp/lookbook/report_<platform>.md` and `.json`, and puts the previous scene back. `zero lookbook report true` adds every block alone. A desktop run of the same command writes `report_native.md`, so the two sit side by side. The numbers do not depend on the frame rate (one measured step per drawn frame), so a slower browser gives the same stability numbers and only the milliseconds differ.
## Parts
- `Lookbook.scene`: the stops, the player setup, a synced empty StyleStack, the `LookbookCamera` the runner routes to the viewport, the excluded object `LookbookExcluded`, the world label and the screen panel.
- `LookbookPath`: the path (`LENGTH`, `STOPS`, `pose(T)`, `segments()`, `extend`).
- `LookbookClock`: the Lookbook's time; `LookbookAnimator`: the component that moves everything from it.
- `Lookbook`: `run`, `compare`, `measure`, `combos`, `frameAt`, `frameRate`, `cost`, `report`, `keyTimes`, `last`, `ensureScene`, `restoreScene`.
- `lookbookProbe` render feature and the `lbStore` / `lbMeasure` compute shaders.
- `lookbook` toolbox (`measure`, `run`, `combos`, `cost`, `report`) and the `lookbook` test suite.
## Known limits
- **The shower-door score (`markStuck`) swings with a few pixels of view size.** Measured 2026-11-01 around 1080p, the same paper (paperBlocks' kraft) scored -0.58 at 1918 x 1080, +0.33 at 1917 x 1077 and +0.50 at 1920 x 1080 in one session, and starterBlocks' `plainPaper` and watercolorBlocks' `wcColdPress` also scored positive at 1920 x 1080 though their papers are laid on world position. The pan's mark strength (`pan.marks`) jumps with it (about 5 against 13), while warped difference, mark growth and GPU times stay put. Until this is understood, compare a look's `markStuck` against another look measured at the same view size in the same session, never against a published number, and check sliding by eye with a slow pan. `result.size` says which size a run used.
- The runner takes the viewport (`camera.setEditorOverride`) while it plays and gives it back after; do not drive the editor camera meanwhile.
- A call made from another scene loads the Lookbook scene and leaves it loaded; `ensureScene` returns the scene it replaced and `restoreScene` puts it back (the test suite does both).
- The numbers are taken one step per drawn frame at the `dt` you pass, so they do not depend on the frame rate; compare stacks measured with the same `dt`. Two runs of the same stack give the same numbers to every printed digit.
- The smoke in the transparency stop is moving cards, since the engine's particle effects cannot be driven by the Lookbook's clock frame by frame.
- Offscreen captures do not draw the stack (styleKit runs after post); the Lookbook therefore always reads presented frames.
## Version history
**1.0.12 (2026-11-02)**: needs styleKit 1.2.3, which keeps the scene's sun on a block after its shader compiles (a fresh install could draw sun-lit blocks without their sun); no block changed.
**1.0.11 (2026-11-01)**: `Lookbook.measure` states the 3D view size its motion numbers were measured at (`result.size`), and refuses a run whose view changed size halfway (an editor panel opening or the window resized), since every number depends on that size; built on styleKit 1.2.2, whose sun no longer carries over from the run before.
**1.0.10 (2026-11-01)**: documentation only: install notes describe how installing works (install from ZeroMind search; installs with several dependencies can be slow) without naming internal ticket numbers.
**1.0.9 (2026-11-01)**: documentation only: the install note says how installing works today (install from ZeroMind search; installs with several dependencies can be slow).
**1.0.8 (2026-11-01)**: the report and `Lookbook.cost` also price the ink looks (brush and wash, comic) and line and wash wherever every block they name is installed (inkBlocks, watercolorBlocks), and the report adds the brush-and-wash ink look to its frame-rate table, so `zero lookbook report` in a web build gives the ink numbers too. Worlds without those kits get the same rows as before.
**1.0.7 (2026-11-01)**: a measurement also waits until no shader has compiled and no GPU pipeline has been built for a whole second after its stack loads, so the first run after a scene load no longer measures frames drawn before the stack's shaders were ready.
**1.0.6 (2026-11-01)**: every measurement waits until the stack it loads has finished compiling and the picture has settled before it starts, so the first run after a stack change no longer measures half-styled frames and two runs give the same numbers.
**1.0.5 (2026-10-31)**: store listing rewritten for creators; built on styleKit 1.0.8 and starterBlocks 1.0.6.
**1.0.4 (2026-10-31)**: depends on starterBlocks as well as styleKit, as it always used both; `Lookbook.frameRate()` measures the frame rate in the scene you are in (no look, high, low, in turns), and `zero lookbook report` starts with it, so a web run states its frame rate.
**1.0.3 (2026-10-31)**: the probe steps and measures one frame per drawn frame, so two runs give identical numbers; `markStuck` tells marks on things from marks on the screen; `compare` writes side-by-side pictures and both stacks' numbers; `run` and `compare` save frames again; every call loads the Lookbook scene itself; `cost` and `report` for 1080p and web numbers; the Lookbook panel no longer dims every frame; new tests for repeatability, stuck marks and combined stacks.
**1.0.2 (2026-10-31)**: runs drive the stack's clock with the Lookbook's time, so a stack with boil or grain gives the same numbers run after run; the probe measures nearly every frame instead of about half; GPU timings are read only once the profiler's window holds the tier alone.
**1.0.1 (2026-10-31)**: `frameAt`, the test suite, frames after a cut left out of the numbers, a run camera of its own, GPU timings counted only for the stack being measured.
**1.0.0 (2026-10-31)**: first release.