322 lines
22 KiB
Markdown
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. |
|