vcti-shader-transform 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.
- vcti_shader_transform-1.0.0/LICENSE +8 -0
- vcti_shader_transform-1.0.0/PKG-INFO +320 -0
- vcti_shader_transform-1.0.0/README.md +291 -0
- vcti_shader_transform-1.0.0/pyproject.toml +94 -0
- vcti_shader_transform-1.0.0/setup.cfg +4 -0
- vcti_shader_transform-1.0.0/src/vcti/shader/transform/__init__.py +147 -0
- vcti_shader_transform-1.0.0/src/vcti/shader/transform/py.typed +0 -0
- vcti_shader_transform-1.0.0/src/vcti/shader/transform/slang/transform.slang +119 -0
- vcti_shader_transform-1.0.0/src/vcti/shader/transform/slang/transform_r32ui.slang +48 -0
- vcti_shader_transform-1.0.0/src/vcti/shader/transform/specs.py +108 -0
- vcti_shader_transform-1.0.0/src/vcti/shader/transform/table.py +233 -0
- vcti_shader_transform-1.0.0/src/vcti/shader/transform/transform.py +438 -0
- vcti_shader_transform-1.0.0/src/vcti_shader_transform.egg-info/PKG-INFO +320 -0
- vcti_shader_transform-1.0.0/src/vcti_shader_transform.egg-info/SOURCES.txt +24 -0
- vcti_shader_transform-1.0.0/src/vcti_shader_transform.egg-info/dependency_links.txt +1 -0
- vcti_shader_transform-1.0.0/src/vcti_shader_transform.egg-info/requires.txt +16 -0
- vcti_shader_transform-1.0.0/src/vcti_shader_transform.egg-info/top_level.txt +1 -0
- vcti_shader_transform-1.0.0/tests/test_definition.py +50 -0
- vcti_shader_transform-1.0.0/tests/test_docs.py +237 -0
- vcti_shader_transform-1.0.0/tests/test_examples.py +268 -0
- vcti_shader_transform-1.0.0/tests/test_interface.py +351 -0
- vcti_shader_transform-1.0.0/tests/test_specs.py +97 -0
- vcti_shader_transform-1.0.0/tests/test_table.py +363 -0
- vcti_shader_transform-1.0.0/tests/test_transform.py +672 -0
- vcti_shader_transform-1.0.0/tests/test_transform_math.py +361 -0
- vcti_shader_transform-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,320 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vcti-shader-transform
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: The transform shader feature: the vertex-stage lookup that places each submesh by a translation, rotation and scale read from a client-owned table.
|
|
5
|
+
Author: Visual Collaboration Technologies Inc.
|
|
6
|
+
License-Expression: LicenseRef-Proprietary
|
|
7
|
+
Project-URL: Repository, https://github.com/vcollab/vcti-python-shader-transform
|
|
8
|
+
Project-URL: Changelog, https://github.com/vcollab/vcti-python-shader-transform/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>=1.0.0
|
|
17
|
+
Provides-Extra: gl
|
|
18
|
+
Requires-Dist: vcti-shader-compiler[gl]>=4.0.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.0.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-transform
|
|
31
|
+
|
|
32
|
+
The transform shader feature: the vertex-stage lookup that places each submesh by a translation, rotation and scale read from a client-owned table.
|
|
33
|
+
|
|
34
|
+
## Overview
|
|
35
|
+
|
|
36
|
+
A viewer needs to move mesh components around while the user works — explode an
|
|
37
|
+
assembly, drag a component aside, turn one to look behind it. Rebuilding
|
|
38
|
+
geometry for each of those is slow and gets slower as the model grows, so this
|
|
39
|
+
feature does it differently: the mesh is divided once into **submeshes** — any
|
|
40
|
+
subsets the client wants to address as units — every vertex carries the id of
|
|
41
|
+
the one it belongs to, and the client keeps a **row per submesh** holding a glTF
|
|
42
|
+
2.0 node's translation, rotation and per-axis scale. The shader fetches each
|
|
43
|
+
vertex's row and places the vertex and its normal by it.
|
|
44
|
+
|
|
45
|
+
Moving any submesh is then one row write. No geometry is rebuilt, no buffer
|
|
46
|
+
repacked, and the cost does not depend on how many vertices the submesh has.
|
|
47
|
+
|
|
48
|
+
Which id the table is indexed by is the client's choice. A transform is usually
|
|
49
|
+
a mesh component's, so a client usually binds its component-id buffer; a client
|
|
50
|
+
that places finer submeshes binds their id buffer instead, and the same shader
|
|
51
|
+
places by it.
|
|
52
|
+
|
|
53
|
+
`vcti-shader-transform` is the shader half of that arrangement. It ships the
|
|
54
|
+
Slang that addresses the table and applies the placement, a Python mirror of
|
|
55
|
+
the same row and the same math so a caller can build a row and predict what the
|
|
56
|
+
shader will do with it, the specs saying what the lookup needs bound, and the
|
|
57
|
+
`ShaderDefinition` saying what the feature is.
|
|
58
|
+
|
|
59
|
+
Everything here is a declaration or fixed shader source. Nothing compiles or
|
|
60
|
+
runs a shader; a build step does that, using what this package declares.
|
|
61
|
+
|
|
62
|
+
## Installation
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install vcti-shader-transform
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Requires Python 3.12, 3.13, or 3.14, matching `vcti-shader-base`. Nothing native
|
|
69
|
+
is built on that path, and nothing native is built by `[test]` either — only the
|
|
70
|
+
`[gl]` extra pulls a GL binding, and only on 3.14 does that compile from source
|
|
71
|
+
for want of a cp314 wheel.
|
|
72
|
+
|
|
73
|
+
### In `requirements.txt`
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
vcti-shader-transform>=1.0.0
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### In `pyproject.toml` dependencies
|
|
80
|
+
|
|
81
|
+
```toml
|
|
82
|
+
dependencies = [
|
|
83
|
+
"vcti-shader-transform>=1.0.0",
|
|
84
|
+
]
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Quick Start
|
|
88
|
+
|
|
89
|
+
### Build a row
|
|
90
|
+
|
|
91
|
+
The client owns the table, so building its rows is the first thing a caller
|
|
92
|
+
does. A row is eleven words: a presence word, then the transform as float bits:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
import math
|
|
96
|
+
from vcti.shader.transform import STRIDE, Transform, axis_angle, pack_row
|
|
97
|
+
|
|
98
|
+
moved = Transform(
|
|
99
|
+
translation=(1.0, 2.0, 3.0),
|
|
100
|
+
rotation=axis_angle((0.0, 0.0, 1.0), math.pi / 2),
|
|
101
|
+
scale=(2.0, 2.0, 2.0),
|
|
102
|
+
)
|
|
103
|
+
row = pack_row(moved)
|
|
104
|
+
assert len(row) == STRIDE == 11
|
|
105
|
+
assert row[0] == 1
|
|
106
|
+
|
|
107
|
+
unmoved = pack_row()
|
|
108
|
+
assert unmoved == (0,) * 11
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The presence word is derived, never passed: it is one exactly when a transform
|
|
112
|
+
was supplied that is not the identity. That is what keeps the word and the ten
|
|
113
|
+
slots from disagreeing, and the shader tests it before reading them — an
|
|
114
|
+
unmoved submesh costs one fetch, and a zero-filled table is a valid table of
|
|
115
|
+
identities rather than a table of collapsed geometry.
|
|
116
|
+
|
|
117
|
+
### Predict what the shader does
|
|
118
|
+
|
|
119
|
+
The same math the shader runs, in Python. Scale in the submesh's own frame,
|
|
120
|
+
then rotate, then translate, the order a glTF node applies its own:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from vcti.shader.transform import unpack_row
|
|
124
|
+
|
|
125
|
+
placed = unpack_row(row).apply((1.0, 0.0, 0.0))
|
|
126
|
+
assert [round(component, 6) for component in placed] == [1.0, 4.0, 3.0]
|
|
127
|
+
assert unpack_row(unmoved) == Transform()
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
A normal goes through the same rotation and the *inverse* of the scale, which
|
|
131
|
+
the package mirrors too, because with a per-axis scale a normal that was only
|
|
132
|
+
rotated lights a stretched submesh wrongly:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
stretched = Transform(scale=(2.0, 1.0, 1.0))
|
|
136
|
+
turned = stretched.apply_normal((0.6, 0.8, 0.0))
|
|
137
|
+
assert [round(component, 4) for component in turned] == [0.3511, 0.9363, 0.0]
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Turn a component about its own centre
|
|
141
|
+
|
|
142
|
+
The scale and rotation in a row act about the **model origin** — there is no
|
|
143
|
+
pivot slot and the shader applies none — so a client turning a mesh component
|
|
144
|
+
about its own centre folds the pivot into the translation. `about_pivot` is that
|
|
145
|
+
fold, and the pivot is the point it leaves alone:
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
component_centre = (10.0, 0.0, 0.0)
|
|
149
|
+
turned_in_place = Transform(rotation=axis_angle((0.0, 0.0, 1.0), math.pi / 2)).about_pivot(
|
|
150
|
+
component_centre
|
|
151
|
+
)
|
|
152
|
+
placed_centre = turned_in_place.apply(component_centre)
|
|
153
|
+
assert [round(component, 6) for component in placed_centre] == [10.0, 0.0, 0.0]
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Without the fold the same rotation would swing the component across the model:
|
|
157
|
+
`Transform(rotation=...).apply((10.0, 0.0, 0.0))` is `(0.0, 10.0, 0.0)`.
|
|
158
|
+
|
|
159
|
+
Scale components must be positive. Zero would flatten the submesh and negative
|
|
160
|
+
would mirror it, so `Transform` refuses both:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
try:
|
|
164
|
+
Transform(scale=(1.0, 0.0, 1.0))
|
|
165
|
+
except ValueError as error:
|
|
166
|
+
assert "not positive" in str(error)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The rotation must be a unit quaternion, refused on the same terms: a non-unit
|
|
170
|
+
one scales as well as rotating, and the shader trusts the row.
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
try:
|
|
174
|
+
Transform(rotation=(2.0, 0.0, 0.0, 0.0))
|
|
175
|
+
except ValueError as error:
|
|
176
|
+
assert "not a unit quaternion" in str(error)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The scalar is **last** — `(x, y, z, w)`. A scalar-first quaternion is usually
|
|
180
|
+
unit, so it passes the check and rotates: `(1.0, 0.0, 0.0, 0.0)` written for the
|
|
181
|
+
identity is a half turn about x here. Build rotations with `axis_angle` and the
|
|
182
|
+
question does not arise.
|
|
183
|
+
|
|
184
|
+
### Upload it
|
|
185
|
+
|
|
186
|
+
The table is an `R32UI` texture, `NEAREST` filtered, with the rows consecutive
|
|
187
|
+
and each row `stride` words. The texture is two-dimensional, because a single
|
|
188
|
+
row would cap the submesh count at whatever `MAX_TEXTURE_SIZE` a device
|
|
189
|
+
reports; a caller picks a width and a word's address wraps across texture rows:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
from vcti.shader.transform import address, rows_needed, texel
|
|
193
|
+
|
|
194
|
+
word = address(1000, 8, STRIDE) # submesh 1000, first scale slot
|
|
195
|
+
assert word == 11_008
|
|
196
|
+
assert texel(word, 2048) == (768, 5)
|
|
197
|
+
assert rows_needed(5000, STRIDE, 2048) == 27
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`pack_table` lays every submesh's row out in that order and pads the last
|
|
201
|
+
texture row, so what it returns is the texture itself — word *a* is the texel
|
|
202
|
+
`texel(a, width)`, and a caller uploads it as-is at that width:
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
from vcti.shader.transform import pack_table
|
|
206
|
+
|
|
207
|
+
table = pack_table([None, moved, None], width=8)
|
|
208
|
+
assert len(table) == rows_needed(3, STRIDE, 8) * 8 == 40
|
|
209
|
+
assert table[address(1, 0, STRIDE)] == 1 # submesh 1's presence word
|
|
210
|
+
assert table[address(0, 0, STRIDE)] == 0 # submesh 0 never moved
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
A `None` entry is an unmoved submesh, so a client with a sparse set of
|
|
214
|
+
placements passes `None` for the rest. The result is a tuple of ints, which a
|
|
215
|
+
caller with an array library wraps before upload — this package has no array
|
|
216
|
+
dependency to return something narrower.
|
|
217
|
+
|
|
218
|
+
Pass the width and stride as `u_transformLutWidth` and `u_transformLutStride`.
|
|
219
|
+
The stride is eleven today; it is a uniform so that a table a later release
|
|
220
|
+
widens still reads correctly in a shader built against this one.
|
|
221
|
+
|
|
222
|
+
### What a build step binds
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
from vcti.shader.transform import vertex_attributes, vertex_uniforms
|
|
226
|
+
|
|
227
|
+
(attribute,) = vertex_attributes()
|
|
228
|
+
assert (attribute.name, attribute.type, attribute.semantic) == (
|
|
229
|
+
"a_transformId", "int", "transform-id"
|
|
230
|
+
)
|
|
231
|
+
assert [u.name for u in vertex_uniforms()] == ["u_transformLutWidth", "u_transformLutStride"]
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
The attribute is named for the feature, not for what the id counts. The client
|
|
235
|
+
binds whichever id buffer it likes to it, and where another feature is keyed by
|
|
236
|
+
the same id, binds the same buffer to that feature's attribute too.
|
|
237
|
+
|
|
238
|
+
The table sampler itself is not in there — a texture input cannot yet be
|
|
239
|
+
expressed in `vcti-shader-base`, so it is contract rather than spec. See
|
|
240
|
+
[docs/design.md](docs/design.md).
|
|
241
|
+
|
|
242
|
+
### Select a pipeline
|
|
243
|
+
|
|
244
|
+
One tag for the behaviour and one naming the table encoding:
|
|
245
|
+
|
|
246
|
+
```python
|
|
247
|
+
from vcti.shader.transform import DEFINITION
|
|
248
|
+
|
|
249
|
+
assert DEFINITION.id == "transform"
|
|
250
|
+
assert DEFINITION.role.value == "vertex"
|
|
251
|
+
assert set(DEFINITION.capabilities) == {"transform", "transform-lut-r32ui"}
|
|
252
|
+
assert DEFINITION.slang_modules == ("transform.slang", "transform_r32ui.slang")
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## The row
|
|
256
|
+
|
|
257
|
+
| Slot | Holds |
|
|
258
|
+
|---|---|
|
|
259
|
+
| 0 | present — 0 for the identity, 1 for a transform in slots 1-10 |
|
|
260
|
+
| 1-3 | translation `x, y, z` as `float32` bits |
|
|
261
|
+
| 4-7 | rotation quaternion `x, y, z, w` as `float32` bits, scalar last |
|
|
262
|
+
| 8-10 | per-axis scale `x, y, z` as `float32` bits, every component positive |
|
|
263
|
+
|
|
264
|
+
Applied as `rotate(q, scale ⊙ p) + t` to a position and
|
|
265
|
+
`normalize(rotate(q, n / scale))` to a normal. Floats are recovered in the
|
|
266
|
+
shader by bit reinterpretation, which is exact: what a row stores, the shader
|
|
267
|
+
reads back unchanged.
|
|
268
|
+
|
|
269
|
+
Getting a value *into* a row is a narrowing conversion, though — Python floats
|
|
270
|
+
are `float64` and a slot is `float32`, so a row holds the nearest `float32` to
|
|
271
|
+
what it was given. Every component must be a finite one: `Transform` refuses an
|
|
272
|
+
infinity, a NaN, or a value past `FLOAT32_MAX`, because a row has no bits for
|
|
273
|
+
the last and a vertex placed by either of the first two has an undefined
|
|
274
|
+
position at rasterization.
|
|
275
|
+
|
|
276
|
+
A scale is checked as the row *stores* it. A positive `float64` such as `1e-50`
|
|
277
|
+
narrows to zero, and a scale of zero is what the positivity rule exists to
|
|
278
|
+
refuse, so the floor is `FLOAT32_MIN_NORMAL` — the smallest *normal* `float32`,
|
|
279
|
+
because a subnormal one may be flushed to zero by the device even though the
|
|
280
|
+
mirror keeps it.
|
|
281
|
+
|
|
282
|
+
## API surface
|
|
283
|
+
|
|
284
|
+
| Name | What it is |
|
|
285
|
+
|---|---|
|
|
286
|
+
| `DEFINITION` | the `ShaderDefinition` a build step imports to compose this feature |
|
|
287
|
+
| `SLANG_DIR`, `SLANG_MODULES` | the installed Slang directory, and the modules in it |
|
|
288
|
+
| `ACCESSOR_MODULE`, `FETCH_MODULE` | the two module names, the second derived from the encoding |
|
|
289
|
+
| `Transform`, `axis_angle`, `rotate` | the placement, and the math the shader mirrors |
|
|
290
|
+
| `Vector3`, `Quaternion` | the tuple aliases the placement is written in |
|
|
291
|
+
| `IDENTITY_ROTATION`, `IDENTITY_SCALE` | what `is_identity` compares against |
|
|
292
|
+
| `Transform.about_pivot` | the same placement with its scale and rotation about a pivot |
|
|
293
|
+
| `ROTATION_TOLERANCE` | how far a rotation may sit from unit before `Transform` refuses it |
|
|
294
|
+
| `pack_row`, `unpack_row`, `is_present` | build a row, read one the way the shader does |
|
|
295
|
+
| `pack_table` | every row laid out in address order, padded to fill the texture |
|
|
296
|
+
| `PRESENT`, `TRANSLATION`, `ROTATION`, `SCALE`, `STRIDE` | the slot assignment |
|
|
297
|
+
| `address`, `texel`, `rows_needed` | the addressing arithmetic |
|
|
298
|
+
| `float_to_bits`, `bits_to_float` | the bit reinterpretation a client in another language reproduces |
|
|
299
|
+
| `FLOAT32_MAX`, `FLOAT32_MIN_NORMAL` | the largest value a slot holds, and the floor on a scale |
|
|
300
|
+
| `vertex_attributes`, `vertex_uniforms`, `ATTRIBUTE`, `WIDTH`, `STRIDE_UNIFORM` | what a build step binds |
|
|
301
|
+
| `CAPABILITY`, `ENCODING`, `ENCODING_CAPABILITY` | the tags, and the shipped texture format |
|
|
302
|
+
|
|
303
|
+
## Dependencies
|
|
304
|
+
|
|
305
|
+
`vcti-shader-base` is the only runtime dependency — declaring a feature is pure
|
|
306
|
+
data. `vcti-shader-compiler>=4.0.0` and `numpy` are test-only, and a separate
|
|
307
|
+
`gl` extra adds the GL binding the shader tests need to execute rather than
|
|
308
|
+
skip.
|
|
309
|
+
|
|
310
|
+
## Documentation
|
|
311
|
+
|
|
312
|
+
| If you want to… | Read |
|
|
313
|
+
|---|---|
|
|
314
|
+
| Get started using the package | Quick Start above |
|
|
315
|
+
| Size, upload and mutate a table | [docs/patterns.md](docs/patterns.md) |
|
|
316
|
+
| Understand the table contract and the decisions behind it | [docs/design.md](docs/design.md) |
|
|
317
|
+
| Navigate or modify the source, including the Slang | [docs/source-guide.md](docs/source-guide.md) |
|
|
318
|
+
|
|
319
|
+
The full API reference is generated from the source docstrings and published in
|
|
320
|
+
the unified VCollab docs.
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# vcti-shader-transform
|
|
2
|
+
|
|
3
|
+
The transform shader feature: the vertex-stage lookup that places each submesh by a translation, rotation and scale read from a client-owned table.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
A viewer needs to move mesh components around while the user works — explode an
|
|
8
|
+
assembly, drag a component aside, turn one to look behind it. Rebuilding
|
|
9
|
+
geometry for each of those is slow and gets slower as the model grows, so this
|
|
10
|
+
feature does it differently: the mesh is divided once into **submeshes** — any
|
|
11
|
+
subsets the client wants to address as units — every vertex carries the id of
|
|
12
|
+
the one it belongs to, and the client keeps a **row per submesh** holding a glTF
|
|
13
|
+
2.0 node's translation, rotation and per-axis scale. The shader fetches each
|
|
14
|
+
vertex's row and places the vertex and its normal by it.
|
|
15
|
+
|
|
16
|
+
Moving any submesh is then one row write. No geometry is rebuilt, no buffer
|
|
17
|
+
repacked, and the cost does not depend on how many vertices the submesh has.
|
|
18
|
+
|
|
19
|
+
Which id the table is indexed by is the client's choice. A transform is usually
|
|
20
|
+
a mesh component's, so a client usually binds its component-id buffer; a client
|
|
21
|
+
that places finer submeshes binds their id buffer instead, and the same shader
|
|
22
|
+
places by it.
|
|
23
|
+
|
|
24
|
+
`vcti-shader-transform` is the shader half of that arrangement. It ships the
|
|
25
|
+
Slang that addresses the table and applies the placement, a Python mirror of
|
|
26
|
+
the same row and the same math so a caller can build a row and predict what the
|
|
27
|
+
shader will do with it, the specs saying what the lookup needs bound, and the
|
|
28
|
+
`ShaderDefinition` saying what the feature is.
|
|
29
|
+
|
|
30
|
+
Everything here is a declaration or fixed shader source. Nothing compiles or
|
|
31
|
+
runs a shader; a build step does that, using what this package declares.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install vcti-shader-transform
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Requires Python 3.12, 3.13, or 3.14, matching `vcti-shader-base`. Nothing native
|
|
40
|
+
is built on that path, and nothing native is built by `[test]` either — only the
|
|
41
|
+
`[gl]` extra pulls a GL binding, and only on 3.14 does that compile from source
|
|
42
|
+
for want of a cp314 wheel.
|
|
43
|
+
|
|
44
|
+
### In `requirements.txt`
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
vcti-shader-transform>=1.0.0
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### In `pyproject.toml` dependencies
|
|
51
|
+
|
|
52
|
+
```toml
|
|
53
|
+
dependencies = [
|
|
54
|
+
"vcti-shader-transform>=1.0.0",
|
|
55
|
+
]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Quick Start
|
|
59
|
+
|
|
60
|
+
### Build a row
|
|
61
|
+
|
|
62
|
+
The client owns the table, so building its rows is the first thing a caller
|
|
63
|
+
does. A row is eleven words: a presence word, then the transform as float bits:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
import math
|
|
67
|
+
from vcti.shader.transform import STRIDE, Transform, axis_angle, pack_row
|
|
68
|
+
|
|
69
|
+
moved = Transform(
|
|
70
|
+
translation=(1.0, 2.0, 3.0),
|
|
71
|
+
rotation=axis_angle((0.0, 0.0, 1.0), math.pi / 2),
|
|
72
|
+
scale=(2.0, 2.0, 2.0),
|
|
73
|
+
)
|
|
74
|
+
row = pack_row(moved)
|
|
75
|
+
assert len(row) == STRIDE == 11
|
|
76
|
+
assert row[0] == 1
|
|
77
|
+
|
|
78
|
+
unmoved = pack_row()
|
|
79
|
+
assert unmoved == (0,) * 11
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The presence word is derived, never passed: it is one exactly when a transform
|
|
83
|
+
was supplied that is not the identity. That is what keeps the word and the ten
|
|
84
|
+
slots from disagreeing, and the shader tests it before reading them — an
|
|
85
|
+
unmoved submesh costs one fetch, and a zero-filled table is a valid table of
|
|
86
|
+
identities rather than a table of collapsed geometry.
|
|
87
|
+
|
|
88
|
+
### Predict what the shader does
|
|
89
|
+
|
|
90
|
+
The same math the shader runs, in Python. Scale in the submesh's own frame,
|
|
91
|
+
then rotate, then translate, the order a glTF node applies its own:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from vcti.shader.transform import unpack_row
|
|
95
|
+
|
|
96
|
+
placed = unpack_row(row).apply((1.0, 0.0, 0.0))
|
|
97
|
+
assert [round(component, 6) for component in placed] == [1.0, 4.0, 3.0]
|
|
98
|
+
assert unpack_row(unmoved) == Transform()
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
A normal goes through the same rotation and the *inverse* of the scale, which
|
|
102
|
+
the package mirrors too, because with a per-axis scale a normal that was only
|
|
103
|
+
rotated lights a stretched submesh wrongly:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
stretched = Transform(scale=(2.0, 1.0, 1.0))
|
|
107
|
+
turned = stretched.apply_normal((0.6, 0.8, 0.0))
|
|
108
|
+
assert [round(component, 4) for component in turned] == [0.3511, 0.9363, 0.0]
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Turn a component about its own centre
|
|
112
|
+
|
|
113
|
+
The scale and rotation in a row act about the **model origin** — there is no
|
|
114
|
+
pivot slot and the shader applies none — so a client turning a mesh component
|
|
115
|
+
about its own centre folds the pivot into the translation. `about_pivot` is that
|
|
116
|
+
fold, and the pivot is the point it leaves alone:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
component_centre = (10.0, 0.0, 0.0)
|
|
120
|
+
turned_in_place = Transform(rotation=axis_angle((0.0, 0.0, 1.0), math.pi / 2)).about_pivot(
|
|
121
|
+
component_centre
|
|
122
|
+
)
|
|
123
|
+
placed_centre = turned_in_place.apply(component_centre)
|
|
124
|
+
assert [round(component, 6) for component in placed_centre] == [10.0, 0.0, 0.0]
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Without the fold the same rotation would swing the component across the model:
|
|
128
|
+
`Transform(rotation=...).apply((10.0, 0.0, 0.0))` is `(0.0, 10.0, 0.0)`.
|
|
129
|
+
|
|
130
|
+
Scale components must be positive. Zero would flatten the submesh and negative
|
|
131
|
+
would mirror it, so `Transform` refuses both:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
try:
|
|
135
|
+
Transform(scale=(1.0, 0.0, 1.0))
|
|
136
|
+
except ValueError as error:
|
|
137
|
+
assert "not positive" in str(error)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The rotation must be a unit quaternion, refused on the same terms: a non-unit
|
|
141
|
+
one scales as well as rotating, and the shader trusts the row.
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
try:
|
|
145
|
+
Transform(rotation=(2.0, 0.0, 0.0, 0.0))
|
|
146
|
+
except ValueError as error:
|
|
147
|
+
assert "not a unit quaternion" in str(error)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The scalar is **last** — `(x, y, z, w)`. A scalar-first quaternion is usually
|
|
151
|
+
unit, so it passes the check and rotates: `(1.0, 0.0, 0.0, 0.0)` written for the
|
|
152
|
+
identity is a half turn about x here. Build rotations with `axis_angle` and the
|
|
153
|
+
question does not arise.
|
|
154
|
+
|
|
155
|
+
### Upload it
|
|
156
|
+
|
|
157
|
+
The table is an `R32UI` texture, `NEAREST` filtered, with the rows consecutive
|
|
158
|
+
and each row `stride` words. The texture is two-dimensional, because a single
|
|
159
|
+
row would cap the submesh count at whatever `MAX_TEXTURE_SIZE` a device
|
|
160
|
+
reports; a caller picks a width and a word's address wraps across texture rows:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
from vcti.shader.transform import address, rows_needed, texel
|
|
164
|
+
|
|
165
|
+
word = address(1000, 8, STRIDE) # submesh 1000, first scale slot
|
|
166
|
+
assert word == 11_008
|
|
167
|
+
assert texel(word, 2048) == (768, 5)
|
|
168
|
+
assert rows_needed(5000, STRIDE, 2048) == 27
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`pack_table` lays every submesh's row out in that order and pads the last
|
|
172
|
+
texture row, so what it returns is the texture itself — word *a* is the texel
|
|
173
|
+
`texel(a, width)`, and a caller uploads it as-is at that width:
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
from vcti.shader.transform import pack_table
|
|
177
|
+
|
|
178
|
+
table = pack_table([None, moved, None], width=8)
|
|
179
|
+
assert len(table) == rows_needed(3, STRIDE, 8) * 8 == 40
|
|
180
|
+
assert table[address(1, 0, STRIDE)] == 1 # submesh 1's presence word
|
|
181
|
+
assert table[address(0, 0, STRIDE)] == 0 # submesh 0 never moved
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
A `None` entry is an unmoved submesh, so a client with a sparse set of
|
|
185
|
+
placements passes `None` for the rest. The result is a tuple of ints, which a
|
|
186
|
+
caller with an array library wraps before upload — this package has no array
|
|
187
|
+
dependency to return something narrower.
|
|
188
|
+
|
|
189
|
+
Pass the width and stride as `u_transformLutWidth` and `u_transformLutStride`.
|
|
190
|
+
The stride is eleven today; it is a uniform so that a table a later release
|
|
191
|
+
widens still reads correctly in a shader built against this one.
|
|
192
|
+
|
|
193
|
+
### What a build step binds
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
from vcti.shader.transform import vertex_attributes, vertex_uniforms
|
|
197
|
+
|
|
198
|
+
(attribute,) = vertex_attributes()
|
|
199
|
+
assert (attribute.name, attribute.type, attribute.semantic) == (
|
|
200
|
+
"a_transformId", "int", "transform-id"
|
|
201
|
+
)
|
|
202
|
+
assert [u.name for u in vertex_uniforms()] == ["u_transformLutWidth", "u_transformLutStride"]
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
The attribute is named for the feature, not for what the id counts. The client
|
|
206
|
+
binds whichever id buffer it likes to it, and where another feature is keyed by
|
|
207
|
+
the same id, binds the same buffer to that feature's attribute too.
|
|
208
|
+
|
|
209
|
+
The table sampler itself is not in there — a texture input cannot yet be
|
|
210
|
+
expressed in `vcti-shader-base`, so it is contract rather than spec. See
|
|
211
|
+
[docs/design.md](docs/design.md).
|
|
212
|
+
|
|
213
|
+
### Select a pipeline
|
|
214
|
+
|
|
215
|
+
One tag for the behaviour and one naming the table encoding:
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
from vcti.shader.transform import DEFINITION
|
|
219
|
+
|
|
220
|
+
assert DEFINITION.id == "transform"
|
|
221
|
+
assert DEFINITION.role.value == "vertex"
|
|
222
|
+
assert set(DEFINITION.capabilities) == {"transform", "transform-lut-r32ui"}
|
|
223
|
+
assert DEFINITION.slang_modules == ("transform.slang", "transform_r32ui.slang")
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## The row
|
|
227
|
+
|
|
228
|
+
| Slot | Holds |
|
|
229
|
+
|---|---|
|
|
230
|
+
| 0 | present — 0 for the identity, 1 for a transform in slots 1-10 |
|
|
231
|
+
| 1-3 | translation `x, y, z` as `float32` bits |
|
|
232
|
+
| 4-7 | rotation quaternion `x, y, z, w` as `float32` bits, scalar last |
|
|
233
|
+
| 8-10 | per-axis scale `x, y, z` as `float32` bits, every component positive |
|
|
234
|
+
|
|
235
|
+
Applied as `rotate(q, scale ⊙ p) + t` to a position and
|
|
236
|
+
`normalize(rotate(q, n / scale))` to a normal. Floats are recovered in the
|
|
237
|
+
shader by bit reinterpretation, which is exact: what a row stores, the shader
|
|
238
|
+
reads back unchanged.
|
|
239
|
+
|
|
240
|
+
Getting a value *into* a row is a narrowing conversion, though — Python floats
|
|
241
|
+
are `float64` and a slot is `float32`, so a row holds the nearest `float32` to
|
|
242
|
+
what it was given. Every component must be a finite one: `Transform` refuses an
|
|
243
|
+
infinity, a NaN, or a value past `FLOAT32_MAX`, because a row has no bits for
|
|
244
|
+
the last and a vertex placed by either of the first two has an undefined
|
|
245
|
+
position at rasterization.
|
|
246
|
+
|
|
247
|
+
A scale is checked as the row *stores* it. A positive `float64` such as `1e-50`
|
|
248
|
+
narrows to zero, and a scale of zero is what the positivity rule exists to
|
|
249
|
+
refuse, so the floor is `FLOAT32_MIN_NORMAL` — the smallest *normal* `float32`,
|
|
250
|
+
because a subnormal one may be flushed to zero by the device even though the
|
|
251
|
+
mirror keeps it.
|
|
252
|
+
|
|
253
|
+
## API surface
|
|
254
|
+
|
|
255
|
+
| Name | What it is |
|
|
256
|
+
|---|---|
|
|
257
|
+
| `DEFINITION` | the `ShaderDefinition` a build step imports to compose this feature |
|
|
258
|
+
| `SLANG_DIR`, `SLANG_MODULES` | the installed Slang directory, and the modules in it |
|
|
259
|
+
| `ACCESSOR_MODULE`, `FETCH_MODULE` | the two module names, the second derived from the encoding |
|
|
260
|
+
| `Transform`, `axis_angle`, `rotate` | the placement, and the math the shader mirrors |
|
|
261
|
+
| `Vector3`, `Quaternion` | the tuple aliases the placement is written in |
|
|
262
|
+
| `IDENTITY_ROTATION`, `IDENTITY_SCALE` | what `is_identity` compares against |
|
|
263
|
+
| `Transform.about_pivot` | the same placement with its scale and rotation about a pivot |
|
|
264
|
+
| `ROTATION_TOLERANCE` | how far a rotation may sit from unit before `Transform` refuses it |
|
|
265
|
+
| `pack_row`, `unpack_row`, `is_present` | build a row, read one the way the shader does |
|
|
266
|
+
| `pack_table` | every row laid out in address order, padded to fill the texture |
|
|
267
|
+
| `PRESENT`, `TRANSLATION`, `ROTATION`, `SCALE`, `STRIDE` | the slot assignment |
|
|
268
|
+
| `address`, `texel`, `rows_needed` | the addressing arithmetic |
|
|
269
|
+
| `float_to_bits`, `bits_to_float` | the bit reinterpretation a client in another language reproduces |
|
|
270
|
+
| `FLOAT32_MAX`, `FLOAT32_MIN_NORMAL` | the largest value a slot holds, and the floor on a scale |
|
|
271
|
+
| `vertex_attributes`, `vertex_uniforms`, `ATTRIBUTE`, `WIDTH`, `STRIDE_UNIFORM` | what a build step binds |
|
|
272
|
+
| `CAPABILITY`, `ENCODING`, `ENCODING_CAPABILITY` | the tags, and the shipped texture format |
|
|
273
|
+
|
|
274
|
+
## Dependencies
|
|
275
|
+
|
|
276
|
+
`vcti-shader-base` is the only runtime dependency — declaring a feature is pure
|
|
277
|
+
data. `vcti-shader-compiler>=4.0.0` and `numpy` are test-only, and a separate
|
|
278
|
+
`gl` extra adds the GL binding the shader tests need to execute rather than
|
|
279
|
+
skip.
|
|
280
|
+
|
|
281
|
+
## Documentation
|
|
282
|
+
|
|
283
|
+
| If you want to… | Read |
|
|
284
|
+
|---|---|
|
|
285
|
+
| Get started using the package | Quick Start above |
|
|
286
|
+
| Size, upload and mutate a table | [docs/patterns.md](docs/patterns.md) |
|
|
287
|
+
| Understand the table contract and the decisions behind it | [docs/design.md](docs/design.md) |
|
|
288
|
+
| Navigate or modify the source, including the Slang | [docs/source-guide.md](docs/source-guide.md) |
|
|
289
|
+
|
|
290
|
+
The full API reference is generated from the source docstrings and published in
|
|
291
|
+
the unified VCollab docs.
|