← SplineCONTENT HISTORY
WHAT CHANGED
Update to Spline
Snapshot Oct 24, 2026 · 13:29 UTC · version 1.2.0
Collection source: downloaded plugin package.
First saved snapshot
No earlier snapshot is available to establish a change.
Save Spline and check future changes in Following.
Up to 10 plugins free · no card required.
Compare saved observations
Download comparison JSONFull technical diff · 0 changed fields
Full snapshot data
{
"description": "Material layer stacks (color, shading/light category, gradient, fresnel, glass/transmission, depth, texture, displace terrain relief), decal stickers/splashes/impact marks stamped onto surfaces, flatShading/side, animated material tweens, layer/addLayer/updateLayer/removeLayer, opacity, lighting for good looks, fog/AO atmosphere, and backgrounds.",
"included_files": [],
"name": "spline-3d-materials-and-look",
"skill_md_contents": "---\nname: spline-3d-materials-and-look\ndescription: \"Material layer stacks (color, shading/light category, gradient, fresnel, glass/transmission, depth, texture, displace terrain relief), decal stickers/splashes/impact marks stamped onto surfaces, flatShading/side, animated material tweens, layer/addLayer/updateLayer/removeLayer, opacity, lighting for good looks, fog/AO atmosphere, and backgrounds.\"\n---\n\n# Materials & Look\n\nA Spline material is a **stack of layers**, like layers in an image editor:\nhigher layers composite over the ones below. The `light` (shading) layer is\nthe pivot — layers BELOW it are the shaded albedo (the default stack is a\n`light` layer with a `color` layer under it); layers ABOVE it composite\nUNLIT over the lit result (fresnel rims, outlines, image planes). New layers\nfrom `layer`/`addLayer` go on top of the stack, except `texture`, which\n`layer()` slots just below the light layer so it gets shaded. You build looks\nby adding/tuning layers.\n\n## The layer helpers\n- `layer(type, props?)` — update the TOP layer of `type` on the selection,\n creating it if missing. The DEFAULT way to edit an existing material look —\n re-running it tunes the same layer instead of stacking duplicates.\n- `color(value)` — convenience: sets the top color layer (and light color).\n Accepts hex/CSS strings (`'#ff8800'`, `'tomato'`, `'rgb(20,30,40)'`).\n- `opacity(alpha)` — sets the color layer's `alpha` (0..1).\n- `addLayer(type, props?)` — push a NEW layer of `type` onto the stack, even if\n one of that type already exists. Returns `{ id }`.\n- `updateLayer(layerId, props)` — update an existing layer by id.\n- `removeLayer(idOrType)` — remove a layer by id, or pass a type to remove the\n top layer of that type.\n- `material(materialState)` — replace the whole material (advanced). Must be a COMPLETE material state including its `layers` array (e.g. one read back from the scene) — partial objects are rejected; use `layer()`/`color()` to tweak.\n Never deep-copy a material read from the scene with `JSON.parse(JSON.stringify(…))` or a recursive spread clone: texture image bytes (a `Uint8Array`) come out as a plain `{ 0: 137, … }` object, a multi-MB edit that breaks the texture. To change one layer, use `updateLayer()`/`layer()` instead.\n- `shading(props)` — convenience for the light layer (see below).\n\nGetting a layer id when you need one (only for stacked duplicates — otherwise\naddress by type): capture `addLayer`'s return, or read it off a selected\nobject — after `select`, a mesh exposes `o.layer(type)` → the layer's props\nplus its `id`.\n```\nlayer('gradient', { angle: 45 }); // edit top gradient (or create)\nconst rim = addLayer('fresnel', { alpha: 0.3 }); // deliberately stack a 2nd one\nupdateLayer(rim.id, { alpha: 0.6 });\nremoveLayer('glass'); // top layer of a type…\nremoveLayer(rim.id); // …or by id\n```\n\n## Reusable material assets (shared looks)\nTHE RULE: the moment two or more objects need the EXACT same material —\nwindow glass, brick walls, metal trim, all the leaves of a forest, any\nrepeated prop — mint a **material asset** once and link everything to it.\nNever rebuild the same layer stack per object: N copies bloat the file,\ndrift out of sync, and turn \"make the windows bluer\" into N edits. Linked\nobjects render from the shared asset, so one `updateMaterial` restyles them\nall. (Deliberate VARIATIONS are the exception — an object whose material\nshould differ gets its own per-object layers, or its own asset if the\nvariant repeats too, e.g. 'Foliage Light' / 'Foliage Dark'.)\n\n- `createMaterial(name, props?)` — save an asset and link the selection to\n it. Starts from the first selected object's current material (build the\n look on one object first), or the standard default if nothing is selected;\n `props.layers` entries apply on top. Same-name calls update the existing\n asset (never duplicate). Returns `{ id, name }`.\n- `applyMaterial(nameOrId)` — link the selection to an existing asset.\n- `updateMaterial(nameOrId, { name?, layers? })` — edit the shared asset;\n EVERY linked object updates at once.\n- `deleteMaterial(nameOrId)` — remove the asset; still-linked objects keep\n their look (each detaches to an inline copy).\n- `listMaterials()` — the overview: `[{ id, name, layers (types only),\n usedBy }]`. The same list rides in every `<editor-context>`/`get_scene`\n snapshot as `materials`, so you usually already know what assets exist.\n- `getMaterial(nameOrId)` — read ONE asset's FULL definition: `{ id, name,\n layers: [{ id, type, …props }], usedBy }`, colors as hex strings\n ('#rrggbbaa' when alpha matters), shared refs resolved, a texture layer's\n `image` as its asset id. The entries are valid `layers` PATCHES: tweak a\n prop and pass a subset straight back to `updateMaterial` (or seed a\n variant via `createMaterial`). An asset minted earlier in the SAME\n run_code call isn't stored yet — read it in the next call.\n\nBoth reads echo their data back in the run_code result (a read-only block\nis valid and returns the data) — so INSPECT before you INVENT: when the\nsnapshot shows a suitable asset, `applyMaterial` it, or `getMaterial` it\nand derive your variant from the real props instead of guessing them.\n\n`layers` entries are `{ type, …props }` patches with `layer()`'s exact\nsemantics and props (update the asset's top layer of that type, or add one;\ncolor strings, nested `texture` patches and `image` ids all work).\n\nCAREFUL: `layer()`/`color()`/etc on an object linked to an asset DETACH that\nobject to a private inline copy first (editing one object never restyles the\nothers) — to change the shared look, go through `updateMaterial`.\n```\nselect('Window 1'); layer('glass', { ior: 1.5 }); color('#bfe3ff');\ncreateMaterial('Window Glass'); // save + link Window 1\nselect((o) => o.name.startsWith('Window')); // all the windows\napplyMaterial('Window Glass'); // link them\nupdateMaterial('Window Glass', { layers: [{ type: 'color', color: '#dff1ff' }] }); // restyles all\n```\n\n## Layer types (`NodeType`)\n`'color'`, `'light'` (shading), `'gradient'`, `'depth'`, `'texture'`,\n`'fresnel'`, `'dust'`, `'noise'`, `'normal'`, `'rainbow'`, `'toon'`,\n`'outline'`, `'matcap'`, `'displace'`, `'pattern'`, `'transmission'`\n(alias `'glass'`),\n`'reflection'` (screen-space reflections; WebGPU renderer only),\n`'vertexColor'` (the geometry's per-vertex color attribute — imported\nvertex-painted meshes; only `alpha`/`mode`, the attribute is the payload),\n`'video'`, `'particle'`.\n\nCommon layer props:\n- All fragment layers: `alpha` (0..1), `mode` (blend mode,\n `FragmentBlendingMode`: `Normal=0`, `Multiply=1`, `Screen=2`,\n `Overlay=3`), and `isMask: true` — turns the layer into a greyscale mask\n for the layer directly below it (white = visible).\n- `color`: `color` (hex/CSS string ok).\n- `light`: `category` ('phong' | 'toon' | 'lambert' | 'physical'), plus\n category-specific `specular`, `shininess`, `emissive`, `roughness`,\n `metalness`, `reflectivity`; also `bumpMap`, `bumpMapIntensity` (any\n category except `lambert`, which ignores bump maps). Physical adds\n texture-layer map refs — `roughnessMap`, `metalnessMap`, `normalMap`\n (+ `normalScale`), `aoMap` (+ `aoMapIntensity`), `emissiveMap`\n (+ `emissiveIntensity`) — see the PBR maps recipe below — and BRDF\n lobes (plain numbers): `clearcoat` (+ `clearcoatRoughness`), `sheen`\n (+ `sheenColor`, `sheenRoughness`), `ior` (+ `specularIntensity`,\n `specularColor`), `iridescence` (+ `iridescenceIOR`,\n `iridescenceThickness`).\n- `gradient`: `colors` (array of hex strings is accepted and converted),\n `angle`, `smooth`. With 3+ colors also pass `steps` (stop positions 0..1,\n e.g. `[0, 0.5, 1]`) — the default steps only place two stops.\n- `depth` (3D gradient by distance): `colors` (+ `steps`), `smooth`, and\n `near` / `far` — the world-unit distance range the gradient spans. No\n `angle` here.\n- `cavity`: `ridge`, `valley` (0..2 — values clamp at 2, the response\n saturates there) — Blender-style screen-space curvature: brightens convex\n edges (ridge), darkens concave creases (valley). Works on HARD,\n un-beveled edges — low-poly rocks, faceted `customMesh` terrain, crisp\n boxes — as well as bevels. Achromatic, overlay-blended over the stack.\n CAVITY IS OPT-IN: add this layer only when the user explicitly asks for\n it (a cavity layer, worn/eroded edges, curvature/edge-wear shading) —\n never as a default finishing move on rocks, terrain, or buildings. When\n asked: start ridge/valley at 1, alpha 0.6-1; 2 is the strong chiseled\n ceiling, 0.3-0.5 a subtle one.\n- `fresnel`: `color`, `scale` (0..1 — 1.0 is the MAX, never exceed it:\n the rim clamps at 1, so a higher scale only blows the rim out into a flat\n wash; for a stronger rim raise `alpha`, for a wider one lower\n `intensity`), `bias`, `intensity` — adds rim light.\n- `dust`: `color`, `coverage` (0..1: 0 = bare, 0.5 = everything above\n horizontal, 1 = fully buried), `softness` (0..1 settle-line feather),\n `noiseStrength`/`noiseScale` (breaks the settle line up) — dust/snow\n that accumulates on up-facing surfaces (world space, UV-free; re-settles\n when the object rotates). Warm grey = dust, white + noise = snow.\n- `transmission` (glass): `roughness` (blur: 0 = clear, 1 = frosted), `ior`\n (refraction, default 1.5), `thickness`, and tinted absorption —\n `attenuationColor` + `attenuationDistance` (world units; 0 = off): thick\n regions shift toward the color, like real colored glass — see glass\n recipe below.\n\nColor-ish keys auto-convert from hex strings: `color`, `specular`, `emissive`,\n`sheenColor`, `specularColor`, `attenuationColor`, `colorA`..`colorD`,\n`shadowColor`, `outlineColor`, `contourColor`, and `colors` (an array). Where the stored color carries a meaningful ALPHA channel — toon\n`shadowColor` (its `a` is the shadow blend amount), the noise/pattern\n`colorA`..`colorD` — a plain hex converts as opaque; use `'rgba(...)'` or an\n`{ r, g, b, a }` object (0..1 each) to set the alpha.\n\n## Shading (the light layer)\n`shading(props)` sets the shading/lighting model of the selected object:\n```\nshading({ category: 'physical', roughness: 0.3, metalness: 0.8 }); // PBR metal\nshading({ category: 'toon' }); // flat cel look\nshading({ category: 'lambert' }); // soft matte\nshading({ category: 'phong', shininess: 30 }); // classic glossy\nshading({ category: 'wireframe' }); // wireframe (special)\n```\n\n`shading` also takes two mesh-level flags:\n- `flatShading: true` — per-face normals: the faceted low-poly look. Essential\n for stylized terrain/rocks built with `customMesh` (indexed meshes otherwise\n render smooth).\n- `side: 'front' | 'back' | 'double'` — render both faces of open surfaces\n (foliage planes, ribbons, the inside of an open cylinder).\n\n## Recipes\n\n### Matte plastic\n```\ncolor('#e0552b');\nshading({ category: 'lambert' });\n```\n\n### Polished metal (PBR)\n```\ncolor('#c9ccd1');\nshading({ category: 'physical', roughness: 0.15, metalness: 1 });\n```\n\nThese recipes use `layer(...)` so they're safe to re-run — tweaking a value\nedits the existing layer instead of stacking a duplicate.\n\n### Glass / transmission\n```\nlayer('glass', { alpha: 1, roughness: 0.05 }); // 'glass' -> 'transmission'\nopacity(0.4);\n```\nGlass ONLY reads against contrast: a transmission surface over a white or\nsame-tone backdrop is invisible — the benchmark's glass-prism scene shipped\nas a blank white frame this way. Put a mid/dark backdrop, a visible\nrefraction subject, or a rim (`fresnel`) behind every glass hero, and check\nits silhouette survives in the live capture.\nColored glass (bottles, gems): add absorption instead of tinting the base\ncolor — `attenuationColor` is the hue thick regions shift toward,\n`attenuationDistance` the world-unit depth where it fully saturates\n(smaller = denser tint; 0 disables):\n```\nlayer('glass', { alpha: 1, roughness: 0.02, thickness: 20,\n attenuationColor: '#2e8b57', attenuationDistance: 30 });\n```\n\n### Rim light (fresnel)\n```\nlayer('fresnel', { alpha: 0.4, scale: 1 }); // scale caps at 1\n```\n\n### Gradient fill (2D — flat shapes ONLY)\n```\nlayer('gradient', { colors: ['#ff0080', '#7928ca'], angle: 90 });\n```\n`gradient` paints a FLAT 2D ramp across the surface — on anything with\nvolume it reads as a STICKER slapped on the shape. Reserve it for\ngenuinely flat geometry (planes, unextruded vector shapes, UI cards,\nbackgrounds). ANY extruded or volumetric shape blends color with\n`depth` instead — the 3D gradient through the object.\n\n### Depth-faded color (3D gradient — the volumetric default)\n```\nlayer('depth', { colors: ['#0b132b', '#3a506b'] });\n```\n`depth` grades color through SPACE, so a volumetric shape shifts hue\nacross its body and reads as lit from within — tune `near`/`far` until\nthe full blend spans the object (screenshot-check: if the shape shows\none flat color, the range is off; if it shows a hard 2D ramp, you used\n`gradient` where `depth` belonged).\n\n### Glassy gradient icons (\"luminous from within\")\nThe premium translucent-icon stack — glossy resin/glass shapes whose\ncolor glows from inside rather than being painted on:\n```\nlayer('depth', { colors: ['#ffb54d', '#ff5d8f'] }); // volume blend, tune near/far\nlayer('glass', { alpha: 1, roughness: 0.08 }); // gloss + refraction\nlayer('fresnel', { color: '#ffffff', alpha: 0.3, scale: 0.8 }); // soft rim catch\nopacity(0.88); // subtle translucency\n```\nVary the `depth` color pair per icon (warm ↔ hot, cool ↔ acid) and keep\none soft key + AO so the curvature carries the highlights.\n\n### Screens & displays (readable on-screen text)\nA dark panel with a dim or default-size label is the recurring failure —\na lit screen is BUILT, in two parts, both mandatory:\n```\n// 1. The panel: near-black in the phosphor family, faintly self-lit.\ncolor('#0a1a10');\nshading({ category: 'phong', emissive: '#102a1a' });\n// 2. The content: its own Text object, parented to the monitor and\n// floated ~2 units off the glass toward the viewer.\nmesh('Text', { text: 'C:\\\\> LOAD \"WORLD\",8,1\\nSEARCHING…\\nREADY.', fontSize: 18 });\ncolor('#39ff6a'); // bright phosphor green\nshading({ category: 'phong', emissive: '#39ff6a' }); // SELF-LIT: readable from\n // any angle, lit or dark room\n```\nSize `fontSize` from the PANEL, never the default: read the panel's real\nwidth from the run_code report / `get_scene()` `size` and pick fontSize ≈\npanelWidth × 1.4 / longest-line-chars, so the longest line spans ~70% of\nthe panel. 3-5 short terminal lines beat one long one. Then LOOK: if the\nnext screenshot's text isn't readable and filling the panel, resize and\nrecolor NOW — bright emissive text is the era-correct hero look. The same\ntwo-part stack does signage, neon, and appliance displays.\nThe texture layer's image comes from ONE of these sources — pick what fits:\n```\nlayer('texture', { image: assetId }); // reuse an EXISTING asset — e.g.\n // an id generate_image or\n // generateTexture returned.\n // Applies immediately.\nlayer('texture', { style: 'weathered copper' }); // OR generate a NEW image from a\n // text prompt (async, see below).\n```\nIf both are given, `image` wins and `style` is ignored — don't pass both.\nThe third source is drawing the image YOURSELF — `generateTexture` (next\nsection) returns an asset id for the `image` key.\n### generateTexture — draw seamless patterns locally (canvas 2D)\n\nTHE way to give real-world surfaces crisp repeating detail (brick, planks,\ntiles, pavement) — local and instant, no image service. Its draw callback\nhas strict seam-wrap rules (rng-only randomness, save/restore transforms,\nopaque base fill, each feature drawn once): load the `texture-drawing`\nskill for the contract and the per-material recipes BEFORE writing one.\n\n### Texture layers on real-world surfaces (mapping & restraint)\n\nA texture layer must NEVER be the object's only look: always set a `color()`\nbase in the same family first and keep the texture layer's `alpha` below 1\n(0.6–0.8) so it tints the base. Generation is async and can fail — while the\nimage is unresolved the layer samples opaque black, so a texture-only\nmaterial renders the object BLACK until (or unless) the image arrives; a\ncolor base keeps it looking intentional the whole time.\n\nReach for surface detail ON YOUR OWN INITIATIVE whenever the requested look\nneeds more than flat colors — \"realistic\"/\"photoreal\" asks, natural\nmaterials, recognizable surfaces — and, because an UNSTATED style defaults\nto the believable register, on the big surfaces of any real-world subject\nthe user didn't style (\"a house\" → brick/stucco walls and shingle roof, \"a\ntable\" → wood grain). Route by the look: REPEATING/GEOMETRIC pattern →\n`generateTexture` (bricks, tiles, planks — crisp, instant, local);\nphotographic/organic → `style` (bark, marble, rust, lava, an earth-like\nplanet). Don't wait for the user to say \"generate a texture\":\n```\nmesh('Sphere', { width: 400, height: 400, depth: 400 });\nlayer('texture', { style: 'earth-like planet surface, oceans and continents, seamless' });\nlayer('fresnel', { color: '#9ec5ff', alpha: 0.45, scale: 1 }); // atmosphere rim\n```\n\nProjection & mapping (get these right or the texture will look broken):\n- `projection`: `'uv'` (default — follows the mesh's own UV unwrap) |\n `'planar'` | `'spherical'` | `'cylindrical'` | `'triplanar'`. Friendly\n names are accepted and converted for you.\n- The `uv` default DISTORTS on anything whose unwrap isn't a clean quad:\n extruded flat shapes (a Triangle-prism roof smears the image along the\n slope), elongated boxes (a tall chimney stretches it vertically), and\n most customMesh geometry. For box-like, extruded, or procedural surfaces\n pass `projection: 'triplanar'` — it maps per axis from object space with\n no stretching. Keep `uv` for cleanly-unwrapped primitives (Sphere, Plane,\n an unstretched Cube face) and `spherical`/`cylindrical` for their shapes.\n- TILING: `texture: { repeat: [x, y] }` (the UI's \"Scale\") tiles the image so\n features read at real size — without it ONE image spans the whole surface\n (stones the size of windows). `texture: { offset: [x, y] }` shifts it;\n `texture: { rotation: r }` turns it.\n- REPEAT IS ALSO RESOLUTION: a tile only has `size` px (512 default), so one\n tile stretched across a whole wall magnifies into blur that NO Sharpness\n setting can fix. Pick repeat from the surface's world size — decide what one\n tile should span (its feature scale: a 4-brick row ≈ 120–160 units, a few\n planks ≈ 150) and set `repeat: [round(surfaceW / span), round(surfaceH /\n span)]` (a 600×450 wall with ~150-unit tiles → `repeat: [4, 3]`). When a\n SINGLE un-tiled image must cover a large hero surface (full-bleed art, a\n planet), generate it at 1024 instead. Per-axis repeat also fixes ASPECT\n smear: `uv` stretches a square texture over the whole face, so on an\n elongated one (a 140×30 sign) features smear ~5× along the long axis —\n `repeat: [5, 1]` (≈ faceW / faceH) restores square features and density.\n- SUBTLETY on real-world surfaces: blend the texture over a base `color()`\n in the same family with `alpha: 0.6–0.8` instead of full-strength, put\n 'subtle', 'fine grain', 'low contrast' in the `style` prompt, and keep\n `bumpMapIntensity` low (1–2). Full-alpha photographic texture + heavy bump\n reads noisy and toy-like in the opposite direction.\n- `axis`: `'x'` | `'y'` | `'z'` — the `planar` projection direction (planar only).\n- `projectionAxis`: `'x'` | `'y'` | `'z'` — the pole axis for `spherical` and\n `cylindrical` (omitted = the legacy `'y'` behavior).\n\nImportant caveats for `style` (generation, not `image`):\n- ONE GENERATION PER STYLE STRING: identical `style` strings reuse the SAME\n generated image (in-block and across the whole session) — near-instant\n after the first, and visually coherent. Spell the style IDENTICALLY on\n every surface that shares a material (\"weathered white stucco, seamless\"\n on all six stucco houses) and vary the per-surface look with\n `texture: { repeat }`, `alpha`, and the base `color()` underneath, not\n with reworded styles. Distinct styles each cost a full generation\n (seconds, and quota): a whole scene wants ~3-6 distinct styles, not 15.\n Generate the hero-surface textures in your FIRST material pass so the\n round-trips overlap with the rest of the build.\n- Image generation is **asynchronous and best-effort** (it already retries\n once internally — never hand-retry). Until the image resolves — or if the\n image backend is unavailable — the layer renders a **checkerboard\n placeholder**. A checkerboard is NOT a bug to fight: either\n re-`take_screenshot()` on a later turn to let it resolve, or, if it persists,\n the backend isn't available — swap that surface to a `generateTexture`\n drawing (patterns need no image service) or build the look procedurally\n (below) instead of retrying the same texture. If `layer('texture')` THROWS\n a \"returned no image\" error, that surface's generation degraded (the error\n says why — quota, moderation, outage): same recovery — `generateTexture`\n first for anything pattern-like, procedural layers otherwise; identical\n styles that already resolved stay reusable.\n- Use concrete, photographic `style` prompts ('cracked desert mud', 'brushed\n steel', 'mossy bark'); very short generic words may match a preset image.\n The image wraps the mesh's UVs — add 'seamless'/'tileable' to the prompt for\n surfaces that wrap around (spheres, cylinders).\n\n### Decals — stamp an image ONTO another object's surface\n\nA `Decal` (`mesh('Decal', { width, height, depth })`) is a projector box\nthat stamps its OWN material onto every mesh surface inside it — the way to\nput a localized image on someone else's surface WITHOUT touching that\nobject's material or UVs: stickers/labels/logos on products, racing numbers\nand livery on vehicle bodywork, paint/ink splashes, bullet impacts and\nscorch marks, graffiti and posters on walls, dirt/wear patches. The stamped\nskin hugs curved surfaces (it clips the real triangles under the box) and\nlifts itself off them (`offset`, default 1), so it cannot z-fight.\n\nThe cutout-sticker recipe — both extra steps are MANDATORY for anything\nthat isn't a full rectangle (splashes, logos, numbers):\n```\nmesh('Decal', { width: 170, height: 170, depth: 300 }); // box through the surface\nremoveLayer('color'); // else the default gray fills the cutout\nconst splat = generateTexture('Paint Splat', (ctx, { size, rng }) => {\n // NO opaque base fill — on a decal, transparent pixels ARE the cutout.\n // (This inverts the usual generateTexture rule; only for decals.)\n ctx.fillStyle = '#ffd400';\n ctx.beginPath(); ctx.arc(size / 2, size / 2, size * 0.26, 0, Math.PI * 2); ctx.fill();\n for (let i = 0; i < 30; i++) { // rng()-scattered droplets\n const a = rng() * Math.PI * 2, r = size * (0.28 + rng() * 0.2);\n ctx.beginPath();\n ctx.arc(size / 2 + Math.cos(a) * r, size / 2 + Math.sin(a) * r,\n size * (0.012 + rng() * 0.045), 0, Math.PI * 2);\n ctx.fill();\n }\n});\nlayer('texture', { image: splat.id });\n```\n- PLACEMENT: the box must INTERSECT the receiving surface — center it on or\n just in front of the surface with a generous `depth`. Only surfaces facing\n the decal receive the stamp; aim it exactly like the flat Rectangle it\n replaces (`rotation({ x: -90 })` floor/tabletop, `{ y: 90 }` +X side\n panel, `{ y: 180 }` far side). Draw-order for text/numbers: the canvas is\n y-down but the stamp reads upright — draw as you would for a screen.\n- `layer('texture', { style: '…' })` (generated) works on decals too, but\n generated images are OPAQUE rectangles — fine for posters, labels, screens\n and full-bleed art; for cutout shapes draw with `generateTexture`.\n- A decal stamps EVERYTHING inside its box: if a tank sticker also hits the\n seat behind it, shrink the box or pull it forward. One decal per detail —\n three bullet holes are three small decals, not one big one.\n- The decal has a full material of its own: `shading` tunes how the stamp is\n lit; a `fresnel` above the light layer, `opacity`-style layer alphas, etc.\n all work. Keep the stack minimal — texture + light is the standard sticker.\n- Ambient occlusion behaves correctly by construction: a decal RECEIVES the\n occlusion of the surface it sits on (it darkens in corners with it) but\n GENERATES none — no AO halo pressed around the sticker. Nothing to tune.\n\n### PBR maps from the texture (physical)\nThe physical light layer can reuse a texture layer as its PBR maps. Pass the\nshorthand `'texture'` — it resolves to the material's TOP texture layer, so\ncreate the texture layer FIRST. Available map slots: `bumpMap`,\n`roughnessMap`, `metalnessMap`, `normalMap`, `aoMap`, `emissiveMap`.\n```\nlayer('texture', { style: 'weathered rusty metal, seamless' });\nshading({ category: 'physical', roughness: 0.85, metalness: 0,\n bumpMap: 'texture', roughnessMap: 'texture', bumpMapIntensity: 6 });\n```\nAs a `bumpMap` the texture's light/dark detail catches the light as surface\nrelief (`bumpMapIntensity` scales it, default 5); as a `roughnessMap` it\nvaries the gloss across the surface (darker areas render glossier). A texture\nlayer id also works in place of the shorthand. The other slots follow the\nsame pattern: `normalMap` treats the texture as a tangent-space normal map\n(`normalScale` dials it), `aoMap` darkens crevices (`aoMapIntensity`),\n`emissiveMap` makes the mapped areas glow — the map MULTIPLIES `emissive`,\nwhich defaults to black, so ALWAYS pass `emissive` (usually `'#ffffff'`)\nwith it, and drive brightness with `emissiveIntensity`. `metalnessMap`\nvaries metalness.\nPacked ORM textures work via per-map channel selects —\n`roughnessMapChannel` / `metalnessMapChannel` / `aoMapChannel`, each\n`'luminance' | 'r' | 'g' | 'b' | 'a'` (defaults: roughness luminance,\nmetalness `b`, AO `r` — the glTF ORM layout). Only wire slots the texture\nactually contains — a photo texture into `normalMap` or `metalnessMap`\nproduces garbage shading.\n\nROUGHNESSMAP IS A LUMINANCE MULTIPLIER — misuse turns walls into mirrors:\nthe sampled luminance multiplies your `roughness` value, so a DARK texture\n(slate roof, asphalt, charcoal brick, dark wood) drags roughness toward 0\nand the surface renders as mirror-glass — it visually reads as TRANSPARENT\n(it reflects the sky/ground in front of it). Only pass `roughnessMap` for\nmid-to-light textures with real gloss variation (rusty metal, wet stone);\nfor dark surfaces keep a flat `roughness` number and use `bumpMap` alone.\nIf a screenshot shows a glassy/see-through wall or roof you textured, remove\nthe `roughnessMap` (`shading({ roughnessMap: undefined })`) or raise\n`roughness`.\n\n### Stylized surfaces without an image (procedural, always renders)\nWhen a texture won't resolve, or the scene is STYLIZED (low-poly, toon, flat\ncolor palettes — where a photo texture would clash), fake material richness\nwith procedural layers + PBR shading + lighting — these never fall back to a\ncheckerboard:\n```\nshading({ category: 'physical', roughness: 0.7, metalness: 0 }); // PBR base\nlayer('depth', { colors: ['#0b2a4a', '#3a7bd5'] }); // tonal depth\nlayer('noise', { alpha: 0.5 }); // surface variation\nlayer('fresnel', { color: '#9ec5ff', alpha: 0.4, scale: 1 }); // rim / atmosphere\n```\nA stylized planet, for instance, reads well as: an ocean `color` base, a\n`noise` layer for continents, a `fresnel` rim for atmosphere, `physical`\nshading, and a key light. A REALISTIC planet should get the texture layer\nabove instead.\n\n### Displace layer — procedural surface relief (terrain without customMesh)\nThe `displace` layer moves the mesh's VERTICES with procedural noise — real\ngeometric relief, not just shading. A subdivided flat shape + displace is the\nlightweight terrain/water path (reach for `customMesh` only when you need a\nspecific silhouette):\n```\nmesh('Plane', { width: 2000, height: 2000, widthSegments: 48, heightSegments: 48 });\nrotation({ x: -90 });\nposition({ y: 0 });\nlayer('displace', { displacementType: 'noise', intensity: 120, scale: 5, seed: 4 });\nshading({ flatShading: true });\ncolor('#5c9e57');\n```\nHigher `intensity` = taller relief. `scale` is a noise FREQUENCY, not a\nfeature size: features span roughly `1000/scale` world units, so LOWER scale\n= broader hills (default 10 ≈ 100-unit features). Keep features several times\nwider than the mesh's segment spacing, or the relief aliases into random\nspikes. The mesh needs segments to displace — an unsubdivided plane won't move.\n\n### Animated material looks (flow, shimmer, pulse)\nMaterial motion is code-driven like all motion (# Motion & interactivity):\nthe motion document writes layer props per frame through the bridge —\n`spline.updateObject(id, { material: { layers: [{ type: <layerType>,\n...props }] } })` — addressed by `type`, `index`, or `id` (load the\n`animation` skill; hero surfaces only):\n```js\n// a \"flowing\" waterfall: swing the gradient angle\nspline.updateObject(falls.id, { material: {\n layers: [{ type: 'gradient', angle: 120 + Math.sin(t * 0.8) * 30 }] } });\n\n// shimmering water: evolve a displace layer's noise\nspline.updateObject(sea.id, { material: {\n layers: [{ type: 'displace', movement: 20 + Math.sin(t * 0.5) * 20 }] } });\n```\nFor RUNTIME-CREATED objects (`spline.createObject`), a custom TSL shader\nmaterial can animate entirely shader-side — `spline.createCustomMaterial`\nwith `tsl.time` in the graph costs zero JS per frame (html-content skill;\nlooks the layer system can't express only — layered materials stay the\ndefault, and NOTE: that bridge call is unrelated to this skill's\n`createMaterial('Name')` shared-asset DSL).\n\n(When the user has explicitly asked for STATES, the same props can be\ntweened instead — `animate({ material: { type: 'gradient', angle: 120 } },\n{ duration: 2600, loop: true, direction: 'alternate' })` — see\n`state-transitions`.)\n\n## Lighting for good looks\nMaterial shading only reads well with scene lights — but reuse what's there:\nif the scene already has lights, `select(o => o.isLight)` and `updateLight` /\nreposition them instead of adding more (1 key is usually enough; 3 lights is\nthe daylight ceiling — night/interior scenes may run more local pools). For a\nscene with none:\n- A key `light('Directional', { intensity: 0.9 })` placed up and to one side.\n- Optionally a softer fill `light('Point', { intensity: 0.4 })` opposite it.\n- DAYLIGHT: keep the key at or below 1 (up to 2 only if the live shot still\n reads flat) — lights stack on the page's ambient fill (default 0.75), and a\n hotter key blows out the very highlights that show off a material.\n NIGHT/INTERIOR: dim the ambient and light with local Points/Spots at 2–8\n shaped by `distance`/`decay` — under-lit PBR reads as grey mud.\n- `physical`/PBR materials especially need decent lighting to show roughness\n and metalness; `toon` and `lambert` are more forgiving.\n\n## Backgrounds & atmosphere\n`background(value)` sets the page background color (hex/CSS string). A soft,\nslightly tinted background flatters most subjects:\n```\nbackground('#dfe3e8');\n```\nTo ground a subject on a plain background WITHOUT a visible floor slab,\ncombine it with a shadow-catcher floor: a Rectangle/Plane with\n`visibility({ shadowCatcher: true })` renders only the shadows cast onto it\n— the background shows through everywhere else (see building-scenes'\nGrounding section). The catcher's material panel is ignored while enabled.\nThe background is flat. For an OUTDOOR scene that wants a real sky, use the\natmospheric `sky()` instead — a physically-based atmosphere with volumetric\nclouds and a sun that also lights the scene:\n```\nsky({ timeOfDay: 17.5, weather: 'partlyCloudy' }); // golden hour\n```\n`timeOfDay` (0-24) sets the sun (~6 sunrise, 12 noon, ~18 sunset); `weather`\nruns 'clear' → 'storm'. While its `lighting` is on (default) the sky's sun +\ndome REPLACE the ambient fill — don't fight it with extra suns; tune\n`lightingIntensity`/`exposure` instead. `sky({ hdri: { image } })` maps an\nequirect environment instead — `image` takes an image asset id, a built-in\nenv preset name ('Forest', 'Neon city', 'Sunset fairway', …), or a URL;\n`sky('none')` returns to the flat color.\nFor a merely GRADED backdrop (stylized/abstract scenes where a simulated sky\nis too literal), put a big `Backdrop` (or a large vertical `Plane`) behind\nthe content and give it a `gradient` layer:\n```\nmesh('Backdrop', { width: 8000, height: 4000 });\nlayer('gradient', { colors: ['#a8d4ff', '#f6ead9'], angle: 90 });\nshading({ category: 'lambert' });\n```\nPair the background with the page environment for depth and grounding:\n- `fog({ useBackgroundColor: true, near: 800, far: 4000 })` — distance fog\n toward the background color; distant objects melt into the sky.\n- `environment({ ao: true, ambient: { color: '#bcd4ff', intensity: 0.7 } })` —\n ambient occlusion (contact darkening that grounds objects) + a tinted\n hemisphere-style ambient fill.\nA scene with zero atmosphere reads as a tech demo — one `fog` call is usually\nthe single highest-impact look upgrade.\n\n## Global look passes\n- `prettify()` — auto-harmonizes all material colors (no args). Good finisher.\n- `style(['#hex', ...], seed?)` — recolors all materials + the background from a\n supplied palette.\n- `studio()` / `stage('studio')` — instant neutral studio: gray ground, key\n light, and two studio materials assigned to the selection.\n- `fx(type, props?)` — page-wide post-processing effects (see below).\n\n## Post-processing effects (`fx`)\nPOST EFFECTS ARE OPT-IN — never part of a default look pass. Enable one ONLY\nwhen the user asks for it, by name or by a look that names it (\"glowing\nneon\" → bloom, \"film grain\" → noise, \"retro pixel look\" → pixelation). The\nsingle exception is `depthOfField`: you may enable it unprompted when it\ngenuinely serves the shot (a close-up hero subject against a distant\nbackground) — skip it otherwise. Do NOT finish scenes with a habitual\nbloom + vignette pass: mood and depth come from fog, lighting, AO, and\npalette (above), not from post effects.\n\n`fx(type, props?)` enables and tunes a page post effect in one call. Pass\n`'none'` (or `null`) as the props to switch one OFF (`fx('vignette', 'none')`),\nor `fx('none')` to clear all post-processing. Every effect also accepts a shared\n`opacity`. The effects and their key props:\n- `'depthOfField'` — camera lens blur: `focusDistance`, `focalLength`,\n `bokehScale`.\n- `'bloom'` — glow bleeding off bright areas: `intensity`, `luminanceThreshold`,\n `luminanceSmoothing`, `blurScale`. The tool for an asked-for neon/emissive glow.\n- `'vignette'` — darkened frame edges: `darkness`, `offset`.\n- `'chromaticAberration'` — RGB colour fringing: `offset` (an `[x, y]` pair).\n- `'hueSaturation'` — `hue`, `saturation`; `'brightnessContrast'` —\n `brightness`, `contrast` (global colour grade).\n- `'noise'` — film grain (tune with `opacity`); `'pixelation'` — `granularity`\n (retro / low-res block look).\n\n```\nfx('depthOfField', { focusDistance: 600, bokehScale: 3 });\n```\n\nShading is hard to predict from code alone — verify material work with a\n`take_screenshot()`, but at MILESTONES (after the whole material + lighting\npass, framed with `set_view`), not after every tweak; the usual budget is one\nor two screenshots per request.\n"
}SHA-256 of public snapshot: 334bc6fd3dcd819966b0e79256f129b1842689ac7481eba34fb9d4f09c339e71