Metadata-Version: 2.4
Name: vcti-shader-material
Version: 1.2.0
Summary: The material shader feature: the fragment-stage lighting that shades a surface by a metallic-roughness material.
Author: Visual Collaboration Technologies Inc.
License-Expression: LicenseRef-Proprietary
Project-URL: Repository, https://github.com/vcollab/vcti-python-shader-material
Project-URL: Changelog, https://github.com/vcollab/vcti-python-shader-material/blob/main/CHANGELOG.md
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Requires-Python: <3.15,>=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: vcti-shader-base>=2.2.0
Provides-Extra: gl
Requires-Dist: vcti-shader-compiler[gl]>=4.2.0; extra == "gl"
Provides-Extra: test
Requires-Dist: pytest; extra == "test"
Requires-Dist: pytest-cov; extra == "test"
Requires-Dist: vcti-shader-compiler>=4.2.0; extra == "test"
Requires-Dist: numpy; extra == "test"
Provides-Extra: lint
Requires-Dist: ruff; extra == "lint"
Provides-Extra: typecheck
Requires-Dist: mypy; extra == "typecheck"
Dynamic: license-file

# vcti-shader-material

The material shader feature: the fragment-stage lighting that shades a surface by a metallic-roughness material.

## Overview

A viewer wants a mesh to look like the object it represents: a painted casting,
a machined steel bracket, a rubber seal. This feature is the lighting that does
that. It shades each fragment by a **metallic-roughness material** — a base colour,
a metallic factor, a roughness, an occlusion factor and an emissive — under a
sky/ground environment and up to four directional or point lights, with the
Cook-Torrance specular model: the GGX distribution, the Smith geometry term and
the Schlick Fresnel approximation.

Those five properties are the model: the smallest set that covers realistic
rendering for the geometry this fleet shows, plus a shading normal that a
normal map may perturb. Anyone who has used a modern real-time renderer will
recognise them, which is the point — they are the standard ones — but what
ships is decided here rather than by conforming to another implementation.

The lighting takes those values as one resolved `MaterialValues` and does not
care where they came from. Four **sources** ship, each a capability of its own:
one material for the whole draw as uniforms, a materials table indexed by a
per-vertex id, authored images sampled at a mesh UV, and the same images with a
normal map. A **layer** transforms a material a source already resolved, and
one ships, the procedural surface finish. None of them changes the BRDF. Tone
mapping and gamma are a separate finishing step the composition applies to a
lit colour and never to a fringe colour, which is a legend colour and must
reach the screen exactly.

`vcti-shader-material` is the shader half of that arrangement. It ships the
Slang that computes the lighting, a Python mirror of every function in it so a
caller can predict a shaded colour and the tests have an oracle, the specs
saying what the lighting needs bound, and the `ShaderDefinition` saying what the
feature is.

Everything here is a declaration or fixed shader source. Nothing compiles or
runs a shader; a build step does that, using what this package declares.

## Installation

```bash
pip install vcti-shader-material
```

Requires Python 3.12, 3.13, or 3.14, matching `vcti-shader-base`. Nothing native
is built on that path, and nothing native is built by `[test]` either — only the
`[gl]` extra pulls a GL binding, and only on 3.14 does that compile from source
for want of a cp314 wheel.

### In `requirements.txt`

```
vcti-shader-material>=1.2.0
```

### In `pyproject.toml` dependencies

```toml
dependencies = [
    "vcti-shader-material>=1.2.0",
]
```

## Quick Start

### Describe a material and some lights

A material is a handful of values. The base colour is a linear colour in
`[0, 1]` — a reflectance, so it has a ceiling; the emissive is a linear radiance
and has none; the three factors are in `[0, 1]`. A light is directional, with the unit direction from the surface
toward it, or a point, with a position and inverse-square falloff:

```python
from vcti.shader.material import Light, MaterialValues

steel = MaterialValues(base_color=(0.56, 0.57, 0.58), metallic=1.0, roughness=0.35)
paint = MaterialValues(base_color=(0.8, 0.1, 0.1), metallic=0.0, roughness=0.5)

key = Light.directional((0.3, 0.4, 1.0), (1.0, 1.0, 1.0))
lamp = Light.point((1.0, 1.0, 3.0), (4.0, 4.0, 4.0))
assert key.is_directional and not lamp.is_directional
```

### Predict what the shader produces

The same math the shader runs, in Python. `shade` returns linear light; `finish`
is the tone mapping and gamma step:

```python
from vcti.shader.material import EnvironmentLight, finish, shade

surface = (0.0, 0.0, 0.0)
normal = (0.0, 0.0, 1.0)
eye = (0.0, 0.0, 5.0)

# The environment is a sky/ground gradient. `uniform` is the flat case, the
# same irradiance from every direction; `studio` is a bright ceiling over a
# darker floor.
environment = EnvironmentLight.studio()

lit = shade(surface, normal, eye, paint, environment, [key, lamp])
assert all(channel > 0.0 for channel in lit)

shown = finish(lit)
assert all(0.0 <= channel < 1.0 for channel in shown)
assert shade(surface, normal, eye, steel, environment, [key]) != shade(
    surface, normal, eye, paint, environment, [key]
)

# A metal is almost entirely a reflection of its surroundings, so which way it
# faces changes what it shows. A flat ambient could not express that.
up = shade(surface, (0.0, 0.0, 1.0), eye, steel, environment, [])
down = shade(surface, (0.0, 0.0, -1.0), (0.0, 0.0, -5.0), steel, environment, [])
assert up[0] > down[0]
```

A light behind the surface contributes nothing, a metal has no diffuse term,
and a roughness below the floor shades as the floor. The material refuses a
factor outside `[0, 1]`:

```python
try:
    MaterialValues(roughness=1.5)
except ValueError as error:
    assert "roughness" in str(error)
```

### Set the uniforms

The material, the scene and the lights are uniforms. Three writers set them by
the names the specs declare — the material once per draw, the environment and
the eye once per frame, and the lights padded to the size the shader is built
for — so nothing a caller has to set is written by hand:

```python
from vcti.shader.material import (
    MAX_LIGHTS,
    environment_uniforms,
    light_uniforms,
    material_uniforms,
)

uniforms = (
    material_uniforms(paint)
    | environment_uniforms(environment, eye)
    | light_uniforms([key, lamp])
)

assert uniforms["u_materialBaseColor"] == (0.8, 0.1, 0.1)
assert uniforms["u_viewPosition"] == eye
assert uniforms["u_environmentUp"] == (0.0, 0.0, 1.0)
assert uniforms["u_lightCount"] == 2
assert len(uniforms["u_lightPositions"]) == MAX_LIGHTS == 4
assert uniforms["u_lightPositions"][1] == (1.0, 1.0, 3.0, 1.0)   # w = 1: a point light
```

### What a build step binds

```python
from vcti.shader.material import fragment_inputs, fragment_outputs, fragment_uniforms

(normal_attribute,) = fragment_inputs()
assert (normal_attribute.name, normal_attribute.type, normal_attribute.semantic) == (
    "a_normal", "vec3", "normal"
)
assert [u.name for u in fragment_uniforms()] == [
    "u_materialBaseColor", "u_materialMetallic", "u_materialRoughness",
    "u_materialOcclusion", "u_materialEmissive",
    "u_viewPosition", "u_environmentSky", "u_environmentGround", "u_environmentUp",
    "u_lightPositions", "u_lightColors", "u_lightCount",
]
assert fragment_outputs()[0].name == "fragColor"
```

The vertex stage rotates the normal into world space, through whatever
placement it applies to the position, and forwards it with the world position
as two varyings every source needs. Two more are conditional: the world tangent
for the normal-mapping source, and the model position for the surface finish
layer. All four are contract rather than spec; see
[docs/design.md](docs/design.md).

### Select a pipeline

One tag for the lighting and one naming where the values come from:

```python
from vcti.shader.material import (
    DEFINITION,
    DEFINITIONS,
    TABLE_DEFINITION,
    TEXTURE_DEFINITION,
    TEXTURE_NORMAL_DEFINITION,
)

assert DEFINITION.id == "material"
assert DEFINITION.role.value == "fragment"
assert set(DEFINITION.capabilities) == {"material", "material-uniform"}
assert DEFINITION.slang_modules == (
    "material.slang",
    "material_table.slang",
    "material_texture.slang",
    "material_surface_finish.slang",
)

# One definition per source. Same lighting tag, different source tag, so a
# pipeline asking for one never matches another.
assert set(TABLE_DEFINITION.capabilities) == {"material", "material-table"}
assert set(TEXTURE_DEFINITION.capabilities) == {"material", "material-texture"}

# Normal mapping is a source of its own, not a flag: it needs a tangent frame
# per vertex, and most CAE geometry carries none.
assert set(TEXTURE_NORMAL_DEFINITION.capabilities) == {"material", "material-texture-normal"}
assert DEFINITIONS == (
    DEFINITION,
    TABLE_DEFINITION,
    TEXTURE_DEFINITION,
    TEXTURE_NORMAL_DEFINITION,
)
```

### Vary a surface without authoring anything

A **layer** takes a resolved material and returns another, so layers compose
where sources cannot: this is how a painted casting can also carry a label. The
surface finish is the first, and it needs no UVs, no tangents and no images —
just the model position, so the finish travels with the surface it is on:

```python
from vcti.shader.material import (
    SURFACE_FINISH_DEFINITION,
    SurfaceFinish,
    apply_surface_finish,
    surface_finish_uniforms,
)

cast_iron = MaterialValues(base_color=(0.35, 0.34, 0.33), roughness=0.7)
casting = SurfaceFinish.cast()

here = apply_surface_finish(cast_iron, (0.0, 0.0, 0.0), casting)
there = apply_surface_finish(cast_iron, (0.12, 0.04, -0.08), casting)
assert here.roughness != there.roughness      # the surface varies from point to point

# The default surface finish is the identity, which is what "no finish" means.
assert apply_surface_finish(cast_iron, (1.0, 2.0, 3.0), SurfaceFinish()) == cast_iron

# A layer is its own definition carrying only its tag, so a pipeline names the
# source it supplies and adds this. Its uniforms are written like the rest.
assert SURFACE_FINISH_DEFINITION.capabilities == ("material-surface-finish",)
assert surface_finish_uniforms(casting) == {
    "u_surfaceFinishScale": 0.05,
    "u_surfaceFinishRoughness": 0.25,
    "u_surfaceFinishColor": 0.1,
}
```

*The* finish (`finish`, `tone_map`, `gamma_encode`) is tone mapping and gamma on
the way to the screen. A *surface* finish is what a surface looks like. They are
unrelated, which is why the tag says `surface-finish` in full.

### Many materials in one draw

The table source replaces the five material uniforms with an id per vertex and
a table of rows. A client uploads `table_texel_rows(...)` as a three-row
`rgba32f` texture and binds the ids; the lighting is unchanged:

```python
from vcti.shader.material import MaterialValues, pack_row, row_material, table_texel_rows

materials = [steel, paint, MaterialValues(base_color=(0.1, 0.1, 0.1), emissive=(0.0, 0.9, 0.2))]
rows = table_texel_rows(materials)

assert len(rows) == 3                      # three texels per material
assert len(rows[0]) == len(materials) * 4  # one column per material
assert row_material(pack_row(steel)) == steel
```

## The lighting

| Term | Model |
|---|---|
| diffuse | Lambert, `base_color / π`, scaled by `(1 − F)(1 − metallic)` |
| distribution | GGX / Trowbridge-Reitz on `roughness²` |
| geometry | Smith, Schlick-GGX form, `k = (roughness + 1)² / 8` |
| Fresnel | Schlick, `F0 = mix(0.04, base_color, metallic)` |
| environment | a sky/ground gradient: diffuse along `N`, specular along `reflect(-V, N)`, weighted by roughness-aware Fresnel |
| tone map | Reinhard `c / (c + 1)` |
| gamma | `c ^ (1 / 2.2)` — skipped on an sRGB framebuffer |
| finish | the two composed |

Roughness is floored at 0.04. Lights are a fixed array of four; `w` in a
light's position selects directional (0) or point (1). Everything is in linear
light until the finish.

## API surface

| Name | What it is |
|---|---|
| `DEFINITION`, `TABLE_DEFINITION`, `TEXTURE_DEFINITION`, `TEXTURE_NORMAL_DEFINITION`, `DEFINITIONS` | one `ShaderDefinition` per source, and the four together |
| `SURFACE_FINISH_DEFINITION` | the layer's definition, carrying only its own tag |
| `SLANG_DIR`, `SLANG_MODULES` | the installed Slang directory, and the four modules in it |
| `MaterialValues`, `Light`, `EnvironmentLight` | the resolved material a source produces, a directional or point light, and the sky/ground environment |
| `shade`, `light_contribution`, `ambient`, `view_direction` | the mirror of the shader's lighting |
| `tone_map`, `gamma_encode`, `finish` | the output transform, in two independently callable steps |
| `fresnel_schlick`, `fresnel_schlick_roughness`, `distribution_ggx`, `geometry_smith`, `geometry_schlick_ggx`, `f0`, `environment_irradiance` | the BRDF's terms |
| `pack_row`, `row_material`, `pack_table`, `table_texel_rows` | the materials table: a row's layout, and the upload shape |
| `texture_values`, `texture_normal` | the texture source's mirror: what it does with a sample, and with a normal-map sample |
| `SurfaceFinish`, `apply_surface_finish` | the surface finish layer, and what it does to a material |
| `material_uniforms`, `environment_uniforms`, `light_uniforms`, `normal_scale_uniforms`, `surface_finish_uniforms` | the uniforms a caller sets, by the declared names |
| `MAX_LIGHTS`, `MIN_ROUGHNESS`, `DIELECTRIC_F0`, `MAX_MATERIALS` | the constants the Slang shares, and the table's ceiling |
| `fragment_inputs`, `fragment_uniforms`, `fragment_outputs`, and the `table_`, `texture_`, `texture_normal_` and `surface_finish_` builders | what a build step binds, per source and for the layer |
| `CAPABILITY`, `SOURCE_CAPABILITY`, `TABLE_SOURCE_CAPABILITY`, `TEXTURE_SOURCE_CAPABILITY`, `TEXTURE_NORMAL_SOURCE_CAPABILITY`, `SURFACE_FINISH_CAPABILITY` | the tags |

## Dependencies

`vcti-shader-base>=2.2.0` is the only runtime dependency — declaring a feature
is pure data. `vcti-shader-compiler>=4.2.0` and `numpy` are test-only, and a separate
`gl` extra adds the GL binding the shader tests need to execute rather than
skip.

## Documentation

| If you want to… | Read |
|---|---|
| Get started using the package | Quick Start above |
| Understand the lighting, the contract and the decisions behind them | [docs/design.md](docs/design.md) |
| Know what a material is, what an authored one loses on the way in, and what is planned | [docs/material-model.md](docs/material-model.md) |
| Understand how authored textures and composed shaders meet, and what was decided | `vcti-shader-base`, `docs/authored-textures.md` — the decisions there settle the texture and sampler declarations, which ship from that package |
| Navigate or modify the source, including the Slang | [docs/source-guide.md](docs/source-guide.md) |

The full API reference is generated from the source docstrings and published in
the unified VCollab docs.
