vcti-shader-material 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. vcti_shader_material-1.0.0/LICENSE +8 -0
  2. vcti_shader_material-1.0.0/PKG-INFO +316 -0
  3. vcti_shader_material-1.0.0/README.md +287 -0
  4. vcti_shader_material-1.0.0/pyproject.toml +106 -0
  5. vcti_shader_material-1.0.0/setup.cfg +4 -0
  6. vcti_shader_material-1.0.0/src/vcti/shader/material/__init__.py +287 -0
  7. vcti_shader_material-1.0.0/src/vcti/shader/material/brdf.py +579 -0
  8. vcti_shader_material-1.0.0/src/vcti/shader/material/py.typed +0 -0
  9. vcti_shader_material-1.0.0/src/vcti/shader/material/slang/material.slang +364 -0
  10. vcti_shader_material-1.0.0/src/vcti/shader/material/slang/material_surface_finish.slang +142 -0
  11. vcti_shader_material-1.0.0/src/vcti/shader/material/slang/material_table.slang +77 -0
  12. vcti_shader_material-1.0.0/src/vcti/shader/material/slang/material_texture.slang +91 -0
  13. vcti_shader_material-1.0.0/src/vcti/shader/material/specs.py +416 -0
  14. vcti_shader_material-1.0.0/src/vcti/shader/material/surface_finish.py +179 -0
  15. vcti_shader_material-1.0.0/src/vcti/shader/material/table.py +172 -0
  16. vcti_shader_material-1.0.0/src/vcti/shader/material/texture.py +124 -0
  17. vcti_shader_material-1.0.0/src/vcti_shader_material.egg-info/PKG-INFO +316 -0
  18. vcti_shader_material-1.0.0/src/vcti_shader_material.egg-info/SOURCES.txt +30 -0
  19. vcti_shader_material-1.0.0/src/vcti_shader_material.egg-info/dependency_links.txt +1 -0
  20. vcti_shader_material-1.0.0/src/vcti_shader_material.egg-info/requires.txt +16 -0
  21. vcti_shader_material-1.0.0/src/vcti_shader_material.egg-info/top_level.txt +1 -0
  22. vcti_shader_material-1.0.0/tests/test_brdf.py +696 -0
  23. vcti_shader_material-1.0.0/tests/test_definition.py +393 -0
  24. vcti_shader_material-1.0.0/tests/test_docs.py +238 -0
  25. vcti_shader_material-1.0.0/tests/test_examples.py +268 -0
  26. vcti_shader_material-1.0.0/tests/test_interface.py +297 -0
  27. vcti_shader_material-1.0.0/tests/test_material_math.py +601 -0
  28. vcti_shader_material-1.0.0/tests/test_specs.py +175 -0
  29. vcti_shader_material-1.0.0/tests/test_surface_finish.py +185 -0
  30. vcti_shader_material-1.0.0/tests/test_table.py +297 -0
  31. vcti_shader_material-1.0.0/tests/test_texture.py +360 -0
  32. vcti_shader_material-1.0.0/tests/test_version.py +15 -0
@@ -0,0 +1,8 @@
1
+ Copyright (c) 2018-2026 Visual Collaboration Technologies Inc.
2
+ All Rights Reserved.
3
+
4
+ This software is proprietary and confidential. Unauthorized copying,
5
+ distribution, or use of this software, via any medium, is strictly
6
+ prohibited. Access is granted only to authorized VCollab developers
7
+ and individuals explicitly authorized by Visual Collaboration
8
+ Technologies Inc.
@@ -0,0 +1,316 @@
1
+ Metadata-Version: 2.4
2
+ Name: vcti-shader-material
3
+ Version: 1.0.0
4
+ Summary: The material shader feature: the fragment-stage lighting that shades a surface by a metallic-roughness material.
5
+ Author: Visual Collaboration Technologies Inc.
6
+ License-Expression: LicenseRef-Proprietary
7
+ Project-URL: Repository, https://github.com/vcollab/vcti-python-shader-material
8
+ Project-URL: Changelog, https://github.com/vcollab/vcti-python-shader-material/blob/main/CHANGELOG.md
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Python: <3.15,>=3.12
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: vcti-shader-base>=2.2.0
17
+ Provides-Extra: gl
18
+ Requires-Dist: vcti-shader-compiler[gl]>=4.2.0; extra == "gl"
19
+ Provides-Extra: test
20
+ Requires-Dist: pytest; extra == "test"
21
+ Requires-Dist: pytest-cov; extra == "test"
22
+ Requires-Dist: vcti-shader-compiler>=4.2.0; extra == "test"
23
+ Requires-Dist: numpy; extra == "test"
24
+ Provides-Extra: lint
25
+ Requires-Dist: ruff; extra == "lint"
26
+ Provides-Extra: typecheck
27
+ Requires-Dist: mypy; extra == "typecheck"
28
+ Dynamic: license-file
29
+
30
+ # vcti-shader-material
31
+
32
+ The material shader feature: the fragment-stage lighting that shades a surface by a metallic-roughness material.
33
+
34
+ ## Overview
35
+
36
+ A viewer wants a mesh to look like the object it represents: a painted casting,
37
+ a brushed steel bracket, a rubber seal. This feature is the lighting that does
38
+ that. It shades each fragment by a **metallic-roughness material** — a base colour,
39
+ a metallic factor, a roughness, an occlusion factor and an emissive — a subset
40
+ of glTF 2.0's metallic-roughness material — under a sky/ground environment and up to four
41
+ directional or point lights, with the Cook-Torrance specular model: the GGX
42
+ distribution, the Smith geometry term and the Schlick Fresnel approximation.
43
+
44
+ The lighting takes those values as one resolved `MaterialValues` and does not
45
+ care where they came from. The source shipped today is **uniforms**: one material for the whole
46
+ draw. A texture, a materials table indexed by a per-vertex id, or the fringe
47
+ colour of a result plot are other sources, each a capability of its own, and
48
+ none of them changes the BRDF. Tone mapping and gamma are a separate finishing
49
+ step the composition applies to a lit colour and never to a fringe colour, which
50
+ is a legend colour and must reach the screen exactly.
51
+
52
+ `vcti-shader-material` is the shader half of that arrangement. It ships the
53
+ Slang that computes the lighting, a Python mirror of every function in it so a
54
+ caller can predict a shaded colour and the tests have an oracle, the specs
55
+ saying what the lighting needs bound, and the `ShaderDefinition` saying what the
56
+ feature is.
57
+
58
+ Everything here is a declaration or fixed shader source. Nothing compiles or
59
+ runs a shader; a build step does that, using what this package declares.
60
+
61
+ ## Installation
62
+
63
+ ```bash
64
+ pip install vcti-shader-material
65
+ ```
66
+
67
+ Requires Python 3.12, 3.13, or 3.14, matching `vcti-shader-base`. Nothing native
68
+ is built on that path, and nothing native is built by `[test]` either — only the
69
+ `[gl]` extra pulls a GL binding, and only on 3.14 does that compile from source
70
+ for want of a cp314 wheel.
71
+
72
+ ### In `requirements.txt`
73
+
74
+ ```
75
+ vcti-shader-material>=1.0.0
76
+ ```
77
+
78
+ ### In `pyproject.toml` dependencies
79
+
80
+ ```toml
81
+ dependencies = [
82
+ "vcti-shader-material>=1.0.0",
83
+ ]
84
+ ```
85
+
86
+ ## Quick Start
87
+
88
+ ### Describe a material and some lights
89
+
90
+ A material is a handful of values. The base colour is a linear colour in
91
+ `[0, 1]` — a reflectance, so it has a ceiling; the emissive is a linear radiance
92
+ and has none; the three factors are in `[0, 1]`. A light is directional, with the unit direction from the surface
93
+ toward it, or a point, with a position and inverse-square falloff:
94
+
95
+ ```python
96
+ from vcti.shader.material import Light, MaterialValues
97
+
98
+ steel = MaterialValues(base_color=(0.56, 0.57, 0.58), metallic=1.0, roughness=0.35)
99
+ paint = MaterialValues(base_color=(0.8, 0.1, 0.1), metallic=0.0, roughness=0.5)
100
+
101
+ key = Light.directional((0.3, 0.4, 1.0), (1.0, 1.0, 1.0))
102
+ lamp = Light.point((1.0, 1.0, 3.0), (4.0, 4.0, 4.0))
103
+ assert key.is_directional and not lamp.is_directional
104
+ ```
105
+
106
+ ### Predict what the shader produces
107
+
108
+ The same math the shader runs, in Python. `shade` returns linear light; `finish`
109
+ is the tone mapping and gamma step:
110
+
111
+ ```python
112
+ from vcti.shader.material import EnvironmentLight, finish, shade
113
+
114
+ surface = (0.0, 0.0, 0.0)
115
+ normal = (0.0, 0.0, 1.0)
116
+ eye = (0.0, 0.0, 5.0)
117
+
118
+ # The environment is a sky/ground gradient. `uniform` is the flat case a single
119
+ # ambient colour used to be; `studio` is a bright ceiling over a darker floor.
120
+ environment = EnvironmentLight.studio()
121
+
122
+ lit = shade(surface, normal, eye, paint, environment, [key, lamp])
123
+ assert all(channel > 0.0 for channel in lit)
124
+
125
+ shown = finish(lit)
126
+ assert all(0.0 <= channel < 1.0 for channel in shown)
127
+ assert shade(surface, normal, eye, steel, environment, [key]) != shade(
128
+ surface, normal, eye, paint, environment, [key]
129
+ )
130
+
131
+ # A metal is almost entirely a reflection of its surroundings, so which way it
132
+ # faces changes what it shows. A flat ambient could not express that.
133
+ up = shade(surface, (0.0, 0.0, 1.0), eye, steel, environment, [])
134
+ down = shade(surface, (0.0, 0.0, -1.0), (0.0, 0.0, -5.0), steel, environment, [])
135
+ assert up[0] > down[0]
136
+ ```
137
+
138
+ A light behind the surface contributes nothing, a metal has no diffuse term,
139
+ and a roughness below the floor shades as the floor. The material refuses a
140
+ factor outside `[0, 1]`:
141
+
142
+ ```python
143
+ try:
144
+ MaterialValues(roughness=1.5)
145
+ except ValueError as error:
146
+ assert "roughness" in str(error)
147
+ ```
148
+
149
+ ### Set the uniforms
150
+
151
+ The material and the lights are uniforms, one set per draw. The helpers write
152
+ them by the names the specs declare, with the light arrays padded to the size the
153
+ shader is built for:
154
+
155
+ ```python
156
+ from vcti.shader.material import MAX_LIGHTS, light_uniforms, material_uniforms
157
+
158
+ uniforms = material_uniforms(paint) | light_uniforms([key, lamp])
159
+ uniforms |= {
160
+ "u_viewPosition": eye,
161
+ "u_environmentSky": environment.sky_color,
162
+ "u_environmentGround": environment.ground_color,
163
+ "u_environmentUp": environment.up,
164
+ }
165
+
166
+ assert uniforms["u_materialBaseColor"] == (0.8, 0.1, 0.1)
167
+ assert uniforms["u_lightCount"] == 2
168
+ assert len(uniforms["u_lightPositions"]) == MAX_LIGHTS == 4
169
+ assert uniforms["u_lightPositions"][1] == (1.0, 1.0, 3.0, 1.0) # w = 1: a point light
170
+ ```
171
+
172
+ ### What a build step binds
173
+
174
+ ```python
175
+ from vcti.shader.material import fragment_inputs, fragment_outputs, fragment_uniforms
176
+
177
+ (normal_attribute,) = fragment_inputs()
178
+ assert (normal_attribute.name, normal_attribute.type, normal_attribute.semantic) == (
179
+ "a_normal", "vec3", "normal"
180
+ )
181
+ assert [u.name for u in fragment_uniforms()] == [
182
+ "u_materialBaseColor", "u_materialMetallic", "u_materialRoughness",
183
+ "u_materialOcclusion", "u_materialEmissive",
184
+ "u_viewPosition", "u_environmentSky", "u_environmentGround", "u_environmentUp",
185
+ "u_lightPositions", "u_lightColors", "u_lightCount",
186
+ ]
187
+ assert fragment_outputs()[0].name == "fragColor"
188
+ ```
189
+
190
+ The vertex stage rotates the normal into world space, through whatever
191
+ placement it applies to the position, and forwards it with the world position
192
+ as two varyings. Those varyings are contract rather than spec; see
193
+ [docs/design.md](docs/design.md).
194
+
195
+ ### Select a pipeline
196
+
197
+ One tag for the lighting and one naming where the values come from:
198
+
199
+ ```python
200
+ from vcti.shader.material import (
201
+ DEFINITION,
202
+ DEFINITIONS,
203
+ TABLE_DEFINITION,
204
+ TEXTURE_DEFINITION,
205
+ )
206
+
207
+ assert DEFINITION.id == "material"
208
+ assert DEFINITION.role.value == "fragment"
209
+ assert set(DEFINITION.capabilities) == {"material", "material-uniform"}
210
+ assert DEFINITION.slang_modules == (
211
+ "material.slang",
212
+ "material_table.slang",
213
+ "material_texture.slang",
214
+ "material_surface_finish.slang",
215
+ )
216
+
217
+ # One definition per source. Same lighting tag, different source tag, so a
218
+ # pipeline asking for one never matches another.
219
+ assert set(TABLE_DEFINITION.capabilities) == {"material", "material-table"}
220
+ assert set(TEXTURE_DEFINITION.capabilities) == {"material", "material-texture"}
221
+ assert DEFINITIONS == (DEFINITION, TABLE_DEFINITION, TEXTURE_DEFINITION)
222
+ ```
223
+
224
+ ### Vary a surface without authoring anything
225
+
226
+ A **layer** takes a resolved material and returns another, so layers compose
227
+ where sources cannot: this is how a painted casting can also carry a label. The
228
+ surface finish is the first, and it needs no UVs, no tangents and no images —
229
+ just world position:
230
+
231
+ ```python
232
+ from vcti.shader.material import SurfaceFinish, apply_surface_finish
233
+
234
+ cast_iron = MaterialValues(base_color=(0.35, 0.34, 0.33), roughness=0.7)
235
+ finish = SurfaceFinish.cast()
236
+
237
+ here = apply_surface_finish(cast_iron, (0.0, 0.0, 0.0), finish)
238
+ there = apply_surface_finish(cast_iron, (0.12, 0.04, -0.08), finish)
239
+ assert here.roughness != there.roughness # the surface varies from point to point
240
+
241
+ # The default finish is the identity, which is what "no finish" means.
242
+ assert apply_surface_finish(cast_iron, (1.0, 2.0, 3.0), SurfaceFinish()) == cast_iron
243
+ ```
244
+
245
+ *The* finish (`finish`, `tone_map`, `gamma_encode`) is tone mapping and gamma on
246
+ the way to the screen. A *surface* finish is what a surface looks like. They are
247
+ unrelated.
248
+
249
+ ### Many materials in one draw
250
+
251
+ The table source replaces the five material uniforms with an id per vertex and
252
+ a table of rows. A client uploads `table_texel_rows(...)` as a three-row
253
+ `rgba32f` texture and binds the ids; the lighting is unchanged:
254
+
255
+ ```python
256
+ from vcti.shader.material import MaterialValues, pack_row, row_material, table_texel_rows
257
+
258
+ materials = [steel, paint, MaterialValues(base_color=(0.1, 0.1, 0.1), emissive=(0.0, 0.9, 0.2))]
259
+ rows = table_texel_rows(materials)
260
+
261
+ assert len(rows) == 3 # three texels per material
262
+ assert len(rows[0]) == len(materials) * 4 # one column per material
263
+ assert row_material(pack_row(steel)) == steel
264
+ ```
265
+
266
+ ## The lighting
267
+
268
+ | Term | Model |
269
+ |---|---|
270
+ | diffuse | Lambert, `base_color / π`, scaled by `(1 − F)(1 − metallic)` |
271
+ | distribution | GGX / Trowbridge-Reitz on `roughness²` |
272
+ | geometry | Smith, Schlick-GGX form, `k = (roughness + 1)² / 8` |
273
+ | Fresnel | Schlick, `F0 = mix(0.04, base_color, metallic)` |
274
+ | environment | a sky/ground gradient: diffuse along `N`, specular along `reflect(-V, N)`, weighted by roughness-aware Fresnel |
275
+ | tone map | Reinhard `c / (c + 1)` |
276
+ | gamma | `c ^ (1 / 2.2)` — skipped on an sRGB framebuffer |
277
+ | finish | the two composed |
278
+
279
+ Roughness is floored at 0.04. Lights are a fixed array of four; `w` in a
280
+ light's position selects directional (0) or point (1). Everything is in linear
281
+ light until the finish.
282
+
283
+ ## API surface
284
+
285
+ | Name | What it is |
286
+ |---|---|
287
+ | `DEFINITION` | the `ShaderDefinition` a build step imports to compose this feature |
288
+ | `SLANG_DIR`, `SLANG_MODULES` | the installed Slang directory, and the module in it |
289
+ | `MaterialValues`, `Light`, `EnvironmentLight` | the resolved material a source produces, a directional or point light, and the sky/ground environment |
290
+ | `shade`, `light_contribution`, `ambient`, `view_direction` | the mirror of the shader's lighting |
291
+ | `tone_map`, `gamma_encode`, `finish` | the output transform, in two independently callable steps |
292
+ | `fresnel_schlick`, `distribution_ggx`, `geometry_smith`, `geometry_schlick_ggx`, `f0` | the BRDF's terms |
293
+ | `material_uniforms`, `light_uniforms` | the uniforms a caller sets, by the declared names |
294
+ | `MAX_LIGHTS`, `MIN_ROUGHNESS`, `DIELECTRIC_F0` | the constants the Slang shares |
295
+ | `fragment_inputs`, `fragment_uniforms`, `fragment_outputs` | what a build step binds |
296
+ | `CAPABILITY`, `SOURCE_CAPABILITY` | the tags |
297
+
298
+ ## Dependencies
299
+
300
+ `vcti-shader-base>=2.1.0` is the only runtime dependency — declaring a feature
301
+ is pure data. `vcti-shader-compiler>=4.0.0` and `numpy` are test-only, and a separate
302
+ `gl` extra adds the GL binding the shader tests need to execute rather than
303
+ skip.
304
+
305
+ ## Documentation
306
+
307
+ | If you want to… | Read |
308
+ |---|---|
309
+ | Get started using the package | Quick Start above |
310
+ | Understand the lighting, the contract and the decisions behind them | [docs/design.md](docs/design.md) |
311
+ | 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) |
312
+ | 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 |
313
+ | Navigate or modify the source, including the Slang | [docs/source-guide.md](docs/source-guide.md) |
314
+
315
+ The full API reference is generated from the source docstrings and published in
316
+ the unified VCollab docs.
@@ -0,0 +1,287 @@
1
+ # vcti-shader-material
2
+
3
+ The material shader feature: the fragment-stage lighting that shades a surface by a metallic-roughness material.
4
+
5
+ ## Overview
6
+
7
+ A viewer wants a mesh to look like the object it represents: a painted casting,
8
+ a brushed steel bracket, a rubber seal. This feature is the lighting that does
9
+ that. It shades each fragment by a **metallic-roughness material** — a base colour,
10
+ a metallic factor, a roughness, an occlusion factor and an emissive — a subset
11
+ of glTF 2.0's metallic-roughness material — under a sky/ground environment and up to four
12
+ directional or point lights, with the Cook-Torrance specular model: the GGX
13
+ distribution, the Smith geometry term and the Schlick Fresnel approximation.
14
+
15
+ The lighting takes those values as one resolved `MaterialValues` and does not
16
+ care where they came from. The source shipped today is **uniforms**: one material for the whole
17
+ draw. A texture, a materials table indexed by a per-vertex id, or the fringe
18
+ colour of a result plot are other sources, each a capability of its own, and
19
+ none of them changes the BRDF. Tone mapping and gamma are a separate finishing
20
+ step the composition applies to a lit colour and never to a fringe colour, which
21
+ is a legend colour and must reach the screen exactly.
22
+
23
+ `vcti-shader-material` is the shader half of that arrangement. It ships the
24
+ Slang that computes the lighting, a Python mirror of every function in it so a
25
+ caller can predict a shaded colour and the tests have an oracle, the specs
26
+ saying what the lighting needs bound, and the `ShaderDefinition` saying what the
27
+ feature is.
28
+
29
+ Everything here is a declaration or fixed shader source. Nothing compiles or
30
+ runs a shader; a build step does that, using what this package declares.
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install vcti-shader-material
36
+ ```
37
+
38
+ Requires Python 3.12, 3.13, or 3.14, matching `vcti-shader-base`. Nothing native
39
+ is built on that path, and nothing native is built by `[test]` either — only the
40
+ `[gl]` extra pulls a GL binding, and only on 3.14 does that compile from source
41
+ for want of a cp314 wheel.
42
+
43
+ ### In `requirements.txt`
44
+
45
+ ```
46
+ vcti-shader-material>=1.0.0
47
+ ```
48
+
49
+ ### In `pyproject.toml` dependencies
50
+
51
+ ```toml
52
+ dependencies = [
53
+ "vcti-shader-material>=1.0.0",
54
+ ]
55
+ ```
56
+
57
+ ## Quick Start
58
+
59
+ ### Describe a material and some lights
60
+
61
+ A material is a handful of values. The base colour is a linear colour in
62
+ `[0, 1]` — a reflectance, so it has a ceiling; the emissive is a linear radiance
63
+ and has none; the three factors are in `[0, 1]`. A light is directional, with the unit direction from the surface
64
+ toward it, or a point, with a position and inverse-square falloff:
65
+
66
+ ```python
67
+ from vcti.shader.material import Light, MaterialValues
68
+
69
+ steel = MaterialValues(base_color=(0.56, 0.57, 0.58), metallic=1.0, roughness=0.35)
70
+ paint = MaterialValues(base_color=(0.8, 0.1, 0.1), metallic=0.0, roughness=0.5)
71
+
72
+ key = Light.directional((0.3, 0.4, 1.0), (1.0, 1.0, 1.0))
73
+ lamp = Light.point((1.0, 1.0, 3.0), (4.0, 4.0, 4.0))
74
+ assert key.is_directional and not lamp.is_directional
75
+ ```
76
+
77
+ ### Predict what the shader produces
78
+
79
+ The same math the shader runs, in Python. `shade` returns linear light; `finish`
80
+ is the tone mapping and gamma step:
81
+
82
+ ```python
83
+ from vcti.shader.material import EnvironmentLight, finish, shade
84
+
85
+ surface = (0.0, 0.0, 0.0)
86
+ normal = (0.0, 0.0, 1.0)
87
+ eye = (0.0, 0.0, 5.0)
88
+
89
+ # The environment is a sky/ground gradient. `uniform` is the flat case a single
90
+ # ambient colour used to be; `studio` is a bright ceiling over a darker floor.
91
+ environment = EnvironmentLight.studio()
92
+
93
+ lit = shade(surface, normal, eye, paint, environment, [key, lamp])
94
+ assert all(channel > 0.0 for channel in lit)
95
+
96
+ shown = finish(lit)
97
+ assert all(0.0 <= channel < 1.0 for channel in shown)
98
+ assert shade(surface, normal, eye, steel, environment, [key]) != shade(
99
+ surface, normal, eye, paint, environment, [key]
100
+ )
101
+
102
+ # A metal is almost entirely a reflection of its surroundings, so which way it
103
+ # faces changes what it shows. A flat ambient could not express that.
104
+ up = shade(surface, (0.0, 0.0, 1.0), eye, steel, environment, [])
105
+ down = shade(surface, (0.0, 0.0, -1.0), (0.0, 0.0, -5.0), steel, environment, [])
106
+ assert up[0] > down[0]
107
+ ```
108
+
109
+ A light behind the surface contributes nothing, a metal has no diffuse term,
110
+ and a roughness below the floor shades as the floor. The material refuses a
111
+ factor outside `[0, 1]`:
112
+
113
+ ```python
114
+ try:
115
+ MaterialValues(roughness=1.5)
116
+ except ValueError as error:
117
+ assert "roughness" in str(error)
118
+ ```
119
+
120
+ ### Set the uniforms
121
+
122
+ The material and the lights are uniforms, one set per draw. The helpers write
123
+ them by the names the specs declare, with the light arrays padded to the size the
124
+ shader is built for:
125
+
126
+ ```python
127
+ from vcti.shader.material import MAX_LIGHTS, light_uniforms, material_uniforms
128
+
129
+ uniforms = material_uniforms(paint) | light_uniforms([key, lamp])
130
+ uniforms |= {
131
+ "u_viewPosition": eye,
132
+ "u_environmentSky": environment.sky_color,
133
+ "u_environmentGround": environment.ground_color,
134
+ "u_environmentUp": environment.up,
135
+ }
136
+
137
+ assert uniforms["u_materialBaseColor"] == (0.8, 0.1, 0.1)
138
+ assert uniforms["u_lightCount"] == 2
139
+ assert len(uniforms["u_lightPositions"]) == MAX_LIGHTS == 4
140
+ assert uniforms["u_lightPositions"][1] == (1.0, 1.0, 3.0, 1.0) # w = 1: a point light
141
+ ```
142
+
143
+ ### What a build step binds
144
+
145
+ ```python
146
+ from vcti.shader.material import fragment_inputs, fragment_outputs, fragment_uniforms
147
+
148
+ (normal_attribute,) = fragment_inputs()
149
+ assert (normal_attribute.name, normal_attribute.type, normal_attribute.semantic) == (
150
+ "a_normal", "vec3", "normal"
151
+ )
152
+ assert [u.name for u in fragment_uniforms()] == [
153
+ "u_materialBaseColor", "u_materialMetallic", "u_materialRoughness",
154
+ "u_materialOcclusion", "u_materialEmissive",
155
+ "u_viewPosition", "u_environmentSky", "u_environmentGround", "u_environmentUp",
156
+ "u_lightPositions", "u_lightColors", "u_lightCount",
157
+ ]
158
+ assert fragment_outputs()[0].name == "fragColor"
159
+ ```
160
+
161
+ The vertex stage rotates the normal into world space, through whatever
162
+ placement it applies to the position, and forwards it with the world position
163
+ as two varyings. Those varyings are contract rather than spec; see
164
+ [docs/design.md](docs/design.md).
165
+
166
+ ### Select a pipeline
167
+
168
+ One tag for the lighting and one naming where the values come from:
169
+
170
+ ```python
171
+ from vcti.shader.material import (
172
+ DEFINITION,
173
+ DEFINITIONS,
174
+ TABLE_DEFINITION,
175
+ TEXTURE_DEFINITION,
176
+ )
177
+
178
+ assert DEFINITION.id == "material"
179
+ assert DEFINITION.role.value == "fragment"
180
+ assert set(DEFINITION.capabilities) == {"material", "material-uniform"}
181
+ assert DEFINITION.slang_modules == (
182
+ "material.slang",
183
+ "material_table.slang",
184
+ "material_texture.slang",
185
+ "material_surface_finish.slang",
186
+ )
187
+
188
+ # One definition per source. Same lighting tag, different source tag, so a
189
+ # pipeline asking for one never matches another.
190
+ assert set(TABLE_DEFINITION.capabilities) == {"material", "material-table"}
191
+ assert set(TEXTURE_DEFINITION.capabilities) == {"material", "material-texture"}
192
+ assert DEFINITIONS == (DEFINITION, TABLE_DEFINITION, TEXTURE_DEFINITION)
193
+ ```
194
+
195
+ ### Vary a surface without authoring anything
196
+
197
+ A **layer** takes a resolved material and returns another, so layers compose
198
+ where sources cannot: this is how a painted casting can also carry a label. The
199
+ surface finish is the first, and it needs no UVs, no tangents and no images —
200
+ just world position:
201
+
202
+ ```python
203
+ from vcti.shader.material import SurfaceFinish, apply_surface_finish
204
+
205
+ cast_iron = MaterialValues(base_color=(0.35, 0.34, 0.33), roughness=0.7)
206
+ finish = SurfaceFinish.cast()
207
+
208
+ here = apply_surface_finish(cast_iron, (0.0, 0.0, 0.0), finish)
209
+ there = apply_surface_finish(cast_iron, (0.12, 0.04, -0.08), finish)
210
+ assert here.roughness != there.roughness # the surface varies from point to point
211
+
212
+ # The default finish is the identity, which is what "no finish" means.
213
+ assert apply_surface_finish(cast_iron, (1.0, 2.0, 3.0), SurfaceFinish()) == cast_iron
214
+ ```
215
+
216
+ *The* finish (`finish`, `tone_map`, `gamma_encode`) is tone mapping and gamma on
217
+ the way to the screen. A *surface* finish is what a surface looks like. They are
218
+ unrelated.
219
+
220
+ ### Many materials in one draw
221
+
222
+ The table source replaces the five material uniforms with an id per vertex and
223
+ a table of rows. A client uploads `table_texel_rows(...)` as a three-row
224
+ `rgba32f` texture and binds the ids; the lighting is unchanged:
225
+
226
+ ```python
227
+ from vcti.shader.material import MaterialValues, pack_row, row_material, table_texel_rows
228
+
229
+ materials = [steel, paint, MaterialValues(base_color=(0.1, 0.1, 0.1), emissive=(0.0, 0.9, 0.2))]
230
+ rows = table_texel_rows(materials)
231
+
232
+ assert len(rows) == 3 # three texels per material
233
+ assert len(rows[0]) == len(materials) * 4 # one column per material
234
+ assert row_material(pack_row(steel)) == steel
235
+ ```
236
+
237
+ ## The lighting
238
+
239
+ | Term | Model |
240
+ |---|---|
241
+ | diffuse | Lambert, `base_color / π`, scaled by `(1 − F)(1 − metallic)` |
242
+ | distribution | GGX / Trowbridge-Reitz on `roughness²` |
243
+ | geometry | Smith, Schlick-GGX form, `k = (roughness + 1)² / 8` |
244
+ | Fresnel | Schlick, `F0 = mix(0.04, base_color, metallic)` |
245
+ | environment | a sky/ground gradient: diffuse along `N`, specular along `reflect(-V, N)`, weighted by roughness-aware Fresnel |
246
+ | tone map | Reinhard `c / (c + 1)` |
247
+ | gamma | `c ^ (1 / 2.2)` — skipped on an sRGB framebuffer |
248
+ | finish | the two composed |
249
+
250
+ Roughness is floored at 0.04. Lights are a fixed array of four; `w` in a
251
+ light's position selects directional (0) or point (1). Everything is in linear
252
+ light until the finish.
253
+
254
+ ## API surface
255
+
256
+ | Name | What it is |
257
+ |---|---|
258
+ | `DEFINITION` | the `ShaderDefinition` a build step imports to compose this feature |
259
+ | `SLANG_DIR`, `SLANG_MODULES` | the installed Slang directory, and the module in it |
260
+ | `MaterialValues`, `Light`, `EnvironmentLight` | the resolved material a source produces, a directional or point light, and the sky/ground environment |
261
+ | `shade`, `light_contribution`, `ambient`, `view_direction` | the mirror of the shader's lighting |
262
+ | `tone_map`, `gamma_encode`, `finish` | the output transform, in two independently callable steps |
263
+ | `fresnel_schlick`, `distribution_ggx`, `geometry_smith`, `geometry_schlick_ggx`, `f0` | the BRDF's terms |
264
+ | `material_uniforms`, `light_uniforms` | the uniforms a caller sets, by the declared names |
265
+ | `MAX_LIGHTS`, `MIN_ROUGHNESS`, `DIELECTRIC_F0` | the constants the Slang shares |
266
+ | `fragment_inputs`, `fragment_uniforms`, `fragment_outputs` | what a build step binds |
267
+ | `CAPABILITY`, `SOURCE_CAPABILITY` | the tags |
268
+
269
+ ## Dependencies
270
+
271
+ `vcti-shader-base>=2.1.0` is the only runtime dependency — declaring a feature
272
+ is pure data. `vcti-shader-compiler>=4.0.0` and `numpy` are test-only, and a separate
273
+ `gl` extra adds the GL binding the shader tests need to execute rather than
274
+ skip.
275
+
276
+ ## Documentation
277
+
278
+ | If you want to… | Read |
279
+ |---|---|
280
+ | Get started using the package | Quick Start above |
281
+ | Understand the lighting, the contract and the decisions behind them | [docs/design.md](docs/design.md) |
282
+ | 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) |
283
+ | 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 |
284
+ | Navigate or modify the source, including the Slang | [docs/source-guide.md](docs/source-guide.md) |
285
+
286
+ The full API reference is generated from the source docstrings and published in
287
+ the unified VCollab docs.