Blacksite/docs/editor/material-system.md
Rbanh 53dc1e44d8
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
feat: add production UI gallery and inspector system
2026-07-18 12:05:26 -04:00

322 lines
22 KiB
Markdown

# Material System
Blacksite uses shared project materials and renderable-owned material slots. Primitives, static
meshes, and skinned meshes expose the same assignment workflow, but retain separate geometry and
hydration ownership. The permanent contracts are [ADR 0035](../adr/0035-shared-material-assets-and-renderer-slots.md),
[ADR 0036](../adr/0036-surface-abi-and-solari-parity.md),
[ADR 0046](../adr/0046-schema-driven-material-inputs-and-processed-textures.md), and
[ADR 0047](../adr/0047-editor-authored-asset-documents.md). Material authoring presentation follows
the shared [editor design system](design-system.md) and
[ADR 0049](../adr/0049-penpot-led-editor-visual-system.md).
## Mental model
| Concept | Owns | Saved? |
|---------|------|--------|
| Material asset | Reusable shader choice, render state, parameters, and textures | Yes, anywhere under `assets/` |
| Material instance | Overrides over one direct base material | Yes, anywhere under `assets/` |
| Material slot assignment | Only the actor/prefab override for a stable primitive or renderer slot | Yes, in scenes/prefabs |
| Imported source material | Read-only default supplied by model import | Yes, as the slot fallback |
| Model material selection | Source, project Material/Instance, or Default for one stable imported slot | Yes, in registry import metadata |
| Property block | Per-owner/per-slot runtime parameter and texture overrides | No |
| DefaultGrid | Immutable engine fallback for missing or unassigned surfaces | Engine built-in |
A material slot assignment stores a `MaterialRef`, not a copy of the material. Model defaults and
imported source selections stay asset-owned in import metadata and runtime manifests instead of
being copied into actor overrides. Every primitive has one stable
`slot:primitive:surface`; mesh renderers use imported stable slot IDs. The registry UUID and subasset
ID identify the target; the cached path is only a loading hint. A material instance may reference a
material, but may not reference another instance.
Resolution is strongest-first: property block, scene/prefab slot, model default, imported source,
project default, then engine DefaultGrid. A broken explicit reference reports a diagnostic and uses
DefaultGrid; it does not silently expose a weaker source assignment. Clearing an assignment is the
explicit way to return to the next layer.
Property blocks are applied after base resolution and cached per owner/slot for both Standard and
Surface materials, including above any sparse values already present on a selected Material
Instance. They never mutate a shared Material asset and remain excluded from scene/prefab
persistence. Unknown properties, schema/type/range mismatches, duplicate names, and broken texture
references reject the complete block with a diagnostic while the resolved base stays visible.
DefaultGrid cannot be edited, instanced, or used as a promotion base.
The editor registers this runtime-only component with Bevy reflection so trusted local BRP tooling
can inject a block for debugging, gameplay integration, and promotion acceptance. Reflection does
not make the component an authoring component: it remains absent from Add Component, excluded from
scene/prefab serialization, and is never published by an asset save.
Surface evaluators specialize forward and deferred material fragments only. Bevy's standard shader
remains authoritative for depth, normal, and motion-vector-only prepasses, so those passes never
try to write a deferred G-buffer attachment that is not present.
When a selected primitive, static renderer, or skinned renderer has a runtime block, its exact material-slot card shows
the override counts and **Promote to Material Instance**. Promotion requires an explicit project
Material or direct-base Material Instance; imported source and DefaultGrid are intentionally
rejected. **Promote to Material Instance** opens a review showing the selected/direct base, exact
slot, sparse override counts, and collision-safe target path. The editor fingerprints the selected
asset, direct base, schema, runtime block, slot assignment, and absent target while the review is
open. Commit merges an existing instance's sparse values with the runtime block, creates one
direct-base Material Instance in the current Content Browser folder, assigns it to that exact scene
slot through one history transaction, and clears the runtime block only after file, registry, and
assignment publication succeed. Cancel or any conflict leaves the block, registry, scene, and
history unchanged.
## Assign materials
Open **Window > Material Library** for the cross-folder Material/Material Instance catalog. It
supports search, Material/Instance/scene-usage filters, grid/list thumbnails, dependency health,
scene-reference counts, creation, drag sources, and the guarded shared-asset editor. It lives in the
existing bottom dock and does not add permanent viewport chrome. Scene-usage rows select the exact
actor, focus the viewport, and identify its renderer slot, primitive surface, or brush face.
Assign through either exact workflow:
1. Drag a Material or Material Instance from the Material Library or Asset Browser onto the desired
viewport surface. A hydrated static/skinned draw targets its exact stable renderer slot, a
primitive targets its stable surface slot, and a brush targets the exact face under the pointer.
2. Check the pointer card and target visual. Green names a valid actor/slot/face; red gives the
blocking reason. Release commits one undoable assignment. Move away, leave the viewport, press
Escape, or right-click to restore the pre-drag state without history or dirtying the scene.
3. Alternatively, select a primitive, static-mesh, or skinned-mesh actor and use its ordered
**Materials** section. Each 82 px expanded or 52 px collapsed slot header is an assignment
target; there is no adjacent drop
box. A valid Material/Instance drag uses the active theme's
selection highlight. A Texture drag
highlights the target as invalid and explains that textures must be authored inside a Material.
**Locate** reveals the current asset; **Clear** removes only the explicit assignment and restores
the inherited model source, project default, or DefaultGrid.
From the Asset Browser, **Apply Material** assigns the selected material to a primitive surface or
every material slot on each selected static/skinned renderer. This is the explicit bulk path. A
viewport hit never silently broadens to all slots.
Direct Texture drops on primitive or mesh slots are rejected because slots store Material
references. Brush faces retain their specialized direct texture path. Create or edit a Material or
Material Instance, assign the texture there, then assign that asset to the slot.
Expand any shared slot to inspect the actual effective material. At the current 620 px wide
reference the slot/body are 596/569 px; at the hard 420 px Inspector floor they are 396/369 px and
six parameter rows reflow to 58 px two-line rows. Project Materials and Instances open the
same asset-keyed editor used by the Content Browser. Valid parameter changes
update `blacksite_surface`'s live document overlay on every interaction frame; its single cache
mutates the shared Standard or Surface handle in place, and there are no Apply/Revert buttons.
The asset-keyed authored document becomes **UNSAVED**, survives selection changes, and performs no
source write, Git refresh, watcher work, thumbnail invalidation, or processing when the pointer is
released. `Ctrl+S` publishes the last edited context; `Ctrl+Shift+S` publishes every dirty editor
document. Scalar, color, label, shader-value, emissive, and render-state saves queue no derived
work; ARM/ORM binding changes queue one coalesced packing job. **Create Instance and Assign** transactionally publishes a
direct-base instance and assigns only the current actor slot for local variation. Imported source
parameters remain read-only; **Extract Editable…** opens extraction for that single source and
assigns the resulting project Material only to the selected actor slot. The project default is
expandable as inherited shared content. DefaultGrid is visible but read-only.
The final panel shows only Shader, Surface, Inputs, shared UV, and supported Advanced Inputs.
Standard Lit stores seven inputs but reports six visible bindings: Emissive Intensity is a
companion of Emissive color and is edited/restored through the same popup. Offset/Tiling changes affect every texture sampler on the Material in
both Standard and Surface rendering; Material Instances may override them sparsely and Reset
restores the base values. Texture fields open their picker on field click and expose Locate and
Clear without a redundant folder action. Direct actor assignments do not receive an “Explicit” label. Source paths, fingerprints,
stable IDs, orphan state, and provenance are secondary diagnostics in overflow rather than material
parameters. The sphere preview and bounded 229.5 px reference identity live in the 82 px assignment block; preview,
identity, and Browse open the compact current/recent Material menu without making Shader or
action clicks assign anything. No second preview, drop box, or material-slot list is rendered.
Project textures are loaded from runtime-catalog sampling data rather than Bevy's default sampler.
Repeat, Clamp, and Mirror map to Repeat, Clamp-to-edge, and Mirror-repeat for U/V/W; authored
filtering and anisotropy apply to Standard, Surface/Solari, property-block bases, and project
material previews. Packed AO/roughness/metallic sources must agree on filter, wrap, and anisotropy
because their canonical ARM artifact has one sampler. A mismatch retains the previous valid
material and reports a processing diagnostic. Imported glTF/FBX materials keep their loader-owned
samplers.
See the [Material Library and targeted-drop evaluation](evaluations/material-library-targeted-drop/)
for the live preview, commit/undo, cancel, and source-verification record.
Static draw slots and material slots are separate. Removing a static draw retains its explicit
material assignment as an orphan rather than guessing a replacement. Reimport also reconciles
slots by stable ID, never by display name. Model Details can **Locate** the preserved project
Material/Instance or **Clear Orphan** before applying the import settings; active project/default
model selections likewise expose **Locate** and **Clear**, with Clear restoring Source.
## Author shared assets
Material files and material-instance files are typed RON documents anywhere under `assets/`. The
Material Library and Asset Browser identify the document kind, expose the same guarded
schema-driven editor and texture bindings, and use stable subasset IDs (`material:source` or
`material:instance`). Material Instance thumbnails resolve the direct base plus sparse overrides.
Built-in shader schemas live under `assets/shaders/`.
Select a Material and use **Create Instance** to create a direct-base variant beside it. The
instance inspector exposes its base Material plus sparse property and texture override checkboxes;
unchecked values continue to inherit. Material instances cannot inherit from other instances.
Material render state exposes Opaque/Cutout, alpha cutoff, and Double Sided in the base Material
inspector.
Materials extracted from textual glTF remain ordinary editable project assets. The extraction
review previews and can exclude each source material, shows converted PBR/render-state/texture
coverage, and permits collision-checked project-path edits before one atomic publication. The same
transaction assigns each affected stable model slot to the newly registered project Material; this
is an explicit model default, not a live source link. Their
Details card also shows read-only provenance: source asset path, source material label/stable index,
and a BLAKE3 source fingerprint. A later extraction detects that stable provenance, shows whether
the source revision, shader selection, render state, or converted PBR values differ, and requires
**Apply Reviewed Update** or **Create New Copy**. Apply replaces the file only while its reviewed
fingerprint still matches and rolls back exact prior bytes if the dependent model mapping cannot
publish. Provenance is not a live link and never authorizes an automatic overwrite.
Every standard and shader-schema texture slot uses the project texture picker. **Browse** lists
texture assets across the project, including imported model texture subassets, and a texture can be
dragged from the Asset Browser directly onto a slot. Non-texture drops are ignored. **Clear** removes
the base Material value; on a Material Instance it removes the sparse override so the slot inherits
from its base again. Dropping or browsing a texture onto an unchecked instance slot creates the
override automatically.
## Schema-driven inputs and packed maps
Material schema v2 stores one canonical `MaterialInputSet`. Standard Lit and custom Surface
shaders both describe their controls with `MaterialInputSchema`, so primitive, static-mesh,
skinned-mesh, Content Browser, and Material Library editors render the same responsive rows. The
exported 569 px section uses one fixed label/value/channel line beside its texture field; the 369 px
section stacks the texture field beneath that line. A bounded transient fallback below 369 px moves
Locate/Clear into overflow instead of allowing negative or out-of-clip geometry. Slot IDs, source
paths, fingerprints, and usage counts are diagnostics rather than material parameters.
Routine visual iteration uses `python scripts/editor/ui_gallery.py watch`, which renders these same
production components against the direct 620/420 Penpot exports without loading material runtime
state. The gallery is diagnostic; final behavior and persistence acceptance still runs in the full
editor. Intentional mock/runtime differences are listed in the
[M2 UI gallery delta matrix](evaluations/content-workspace-m2/ui-gallery-delta-matrix.md).
In renderer arrays, collapsing Parameters does not have to erase material identity or assignment
actions. The shared slot can retain its complete asset header while the parameter body is closed;
secondary compact rows and genuinely empty drop targets are explicit states of that same component,
not separate renderer-only material widgets.
Base Color is a multiplier over its albedo texture. Metallic and Roughness are scalar multipliers
over their selected channel; Occlusion is a scalar texture input. The **ARM / glTF ORM** preset uses
R=ambient occlusion, G=roughness, and B=metallic. **Separate Maps** samples each map's R channel,
while the adjacent channel selectors support custom packed layouts. Publication repacks those
choices into one canonical linear R/G/B ARM artifact used by Standard and Surface/Solari paths.
Select a Texture asset to edit its semantic, color space, mipmaps, compression, maximum size,
filtering, wrapping, anisotropy, and normal convention. Auto treats color/emissive maps as sRGB and
normal/mask/ARM data as Linear. DirectX normals are converted to OpenGL +Y during processing.
Texture property changes use the same **UNSAVED** document state. Saving preserves the source image
and queues a content-addressed UASTC Basis or uncompressed KTX2 artifact below managed
`.import-cache/runtime/` storage. The processor derives the output key from source bytes and
normalized settings before decoding; if that exact artifact already exists, editor refresh,
packaging, and `process-assets --check` reuse it without recompression.
Loose PBR image sets can be turned into editable Material assets from the Content Browser with
**Create Materials From Folder**. The preview groups filename suffixes, reports confidence, and lets
authors correct roles or edit a target name to merge/split sets before publication. Base-color,
normal, and packed ORM/ARM maps populate the Standard material fields; occlusion and separately
authored roughness, metallic, or height maps are also retained as named Surface texture bindings.
The batch is transactional and never overwrites an existing Material file. See the
[content-workspace guide](content-workspace.md) for the full workflow.
The supported render states are:
- **Opaque** — no alpha candidate test.
- **Cutout** — alpha is compared with the material's cutoff in raster and, for eligible geometry,
at Solari ray candidates.
- **Double sided** — stored on the shared material render state.
Editing a shared asset never writes copies into renderer components. The runtime watches the
Material, direct base, shader schema, and evaluator dependencies and updates the shared hydrated
handle in place. Standard and custom Surface changes therefore propagate to every assigned static
or skinned renderer slot without reloading geometry, joints, or the current animation pose. Invalid
Surface edits retain the last-good evaluator generation and emit diagnostics. The neutral
`StandardMaterial` attached during hydration is only a singleton emergency visibility handle. It
is never reused for an assigned asset: the resolver caches one live handle per `MaterialRef`, so
users of the same Material share updates while unrelated Materials cannot overwrite one another.
Renderer and terrain material bindings target generated draw entities. Scene switches may remove
those entities between binding discovery and deferred command application, so binding replacement
uses fallible entity commands and treats that stale-target race as normal lifecycle cleanup rather
than an editor-fatal error.
## Custom Surface evaluators
A custom shader schema may reference evaluator WGSL. Evaluators return material properties through
one constrained entry point:
```wgsl
fn evaluate(
input: SurfaceInput,
params: SurfaceParams,
samples: SurfaceSamples,
) -> Surface {
var surface = surface_default();
surface.base_color = samples.values[0];
surface.perceptual_roughness = params.lanes[0].x;
return surface;
}
```
Surface ABI v1 supplies UV0, world position, world normal, 16 parameter lanes, and eight sampled 2D
textures. The evaluator returns base color/alpha, tangent-space normal, emissive, metallic,
roughness, reflectance, occlusion, and lit/unlit model selection. Mesh tangents are required for a
custom tangent-space normal to affect raster output and for Solari mesh compatibility.
Do not declare bindings or `@vertex`, `@fragment`, or `@compute` entry points. Storage resources,
ray queries, derivatives, discard, barriers/subgroups, texture stores, and atomics are rejected.
The engine owns the render stages and composes the same validated evaluator into raster and Solari.
## Solari scope
For eligible non-deformed triangle geometry, Solari uses the same Surface evaluator, packed
parameters, textures, normal, emissive/unlit choice, and cutout alpha decision as raster rendering.
The standard material path remains unchanged for materials without a custom evaluator. See the
[rendering guide](rendering.md) for GI selection and fallback diagnostics.
Skinned meshes and morph-deformed meshes are currently excluded from Solari. This prevents rays
from tracing their undeformed source/bind pose while raster shows the animated pose. They remain
visible in raster, and Rendering diagnostics report the deformed exclusion. Dynamic deformed
vertex/BLAS updates are tracked independently by Gitea #54 with animation, ray-hit, fallback,
memory, and update-cost acceptance.
Surface ABI v1 does not support blended transparency, transmission/refraction, custom vertex
displacement, or arbitrary pipeline stages.
`assets/materials/surface_tint.ron` and `assets/shaders/surface_tint.*` are the committed minimal
custom-Surface example used for editor and renderer verification.
## Upgrade and validate a project
Normal project loading does not rewrite material or scene files. Preview the explicit transactional
upgrade first:
```bash
cargo upgrade-project --project .
cargo upgrade-project --project . --apply
```
Repository scenes use schema v6 for actor-only slot ownership. This pre-production project updates
fixtures directly rather than carrying a generalized v5 compatibility migrator. The explicit
upgrade path converts legacy primitive/mesh descriptors and per-slot overrides into generalized saved slots, and deduplicates canonical descriptors into project
Materials under `assets/materials/migrated/`. It stages the complete rewrite, creates a timestamped
backup under `.blacksite/backups/material-slot-v5-*`, and rolls back on failure. Commit or otherwise back up
the project before applying, inspect the diff afterward, then run:
```bash
cargo --locked validate-levels
```
Validation reports unresolved material/schema/texture references, nested or incompatible instance
bases, and unsupported project content through the same editor/headless finding model.
## Troubleshooting
| Symptom | Check |
|---------|-------|
| Clearing a slot appears to do nothing | Clear removes the explicit override; the imported source material is still effective. |
| Assignment disappeared after reimport | Look for an orphaned assignment. Slots are intentionally not matched by name. |
| Skinned material edit resets the pose | This is a regression: material-only changes must patch the existing hierarchy rather than reload it. |
| Custom evaluator is rejected | Check the required `evaluate` signature, ABI lane/texture limits, and forbidden constructs. |
| Custom normal has no visible effect | Verify the mesh has tangents. |
| Animated actor is missing from Solari GI | Expected for current skinned/morph geometry; inspect the deformed-exclusion diagnostic. |
| Material reference is unresolved | Run project validation and repair/reselect the stable asset reference; do not hand-edit a display-name match. |
| Texture drop is red on an imported renderer | Renderer slots accept Materials, not loose textures. Create/edit a Material Instance, set its texture, then assign it. |
| Drag preview remains after leaving a target | This is a regression: target changes, viewport exit, Escape, right-click, and invalid release must restore the exact snapshot. |