vcti-shader-transform
The transform shader feature: the vertex-stage lookup that places each submesh by a translation, rotation and scale read from a client-owned table.
Overview
A viewer needs to move mesh components around while the user works — explode an assembly, drag a component aside, turn one to look behind it. Rebuilding geometry for each of those is slow and gets slower as the model grows, so this feature does it differently: the mesh is divided once into submeshes — any subsets the client wants to address as units — every vertex carries the id of the one it belongs to, and the client keeps a row per submesh holding a glTF 2.0 node's translation, rotation and per-axis scale. The shader fetches each vertex's row and places the vertex and its normal by it.
Moving any submesh is then one row write. No geometry is rebuilt, no buffer repacked, and the cost does not depend on how many vertices the submesh has.
Which id the table is indexed by is the client's choice. A transform is usually a mesh component's, so a client usually binds its component-id buffer; a client that places finer submeshes binds their id buffer instead, and the same shader places by it.
vcti-shader-transform is the shader half of that arrangement. It ships the
Slang that addresses the table and applies the placement, a Python mirror of
the same row and the same math so a caller can build a row and predict what the
shader will do with it, the specs saying what the lookup 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
pip install vcti-shader-transform
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-transform>=1.1.0
In pyproject.toml dependencies
dependencies = [
"vcti-shader-transform>=1.1.0",
]
Quick Start
Build a row
The client owns the table, so building its rows is the first thing a caller does. A row is eleven words: a presence word, then the transform as float bits:
import math
from vcti.shader.transform import STRIDE, Transform, axis_angle, pack_row
moved = Transform(
translation=(1.0, 2.0, 3.0),
rotation=axis_angle((0.0, 0.0, 1.0), math.pi / 2),
scale=(2.0, 2.0, 2.0),
)
row = pack_row(moved)
assert len(row) == STRIDE == 11
assert row[0] == 1
unmoved = pack_row()
assert unmoved == (0,) * 11
The presence word is derived, never passed: it is one exactly when a transform was supplied that is not the identity. That is what keeps the word and the ten slots from disagreeing, and the shader tests it before reading them — an unmoved submesh costs one fetch, and a zero-filled table is a valid table of identities rather than a table of collapsed geometry.
Predict what the shader does
The same math the shader runs, in Python. Scale in the submesh's own frame, then rotate, then translate, the order a glTF node applies its own:
from vcti.shader.transform import unpack_row
placed = unpack_row(row).apply((1.0, 0.0, 0.0))
assert [round(component, 6) for component in placed] == [1.0, 4.0, 3.0]
assert unpack_row(unmoved) == Transform()
A normal goes through the same rotation and the inverse of the scale, which the package mirrors too, because with a per-axis scale a normal that was only rotated lights a stretched submesh wrongly:
stretched = Transform(scale=(2.0, 1.0, 1.0))
turned = stretched.apply_normal((0.6, 0.8, 0.0))
assert [round(component, 4) for component in turned] == [0.3511, 0.9363, 0.0]
Turn a component about its own centre
The scale and rotation in a row act about the model origin — there is no
pivot slot and the shader applies none — so a client turning a mesh component
about its own centre folds the pivot into the translation. about_pivot is that
fold, and the pivot is the point it leaves alone:
component_centre = (10.0, 0.0, 0.0)
turned_in_place = Transform(rotation=axis_angle((0.0, 0.0, 1.0), math.pi / 2)).about_pivot(
component_centre
)
placed_centre = turned_in_place.apply(component_centre)
assert [round(component, 6) for component in placed_centre] == [10.0, 0.0, 0.0]
Without the fold the same rotation would swing the component across the model:
Transform(rotation=...).apply((10.0, 0.0, 0.0)) is (0.0, 10.0, 0.0).
Scale components must be positive. Zero would flatten the submesh and negative
would mirror it, so Transform refuses both:
try:
Transform(scale=(1.0, 0.0, 1.0))
except ValueError as error:
assert "not positive" in str(error)
The rotation must be a unit quaternion, refused on the same terms: a non-unit one scales as well as rotating, and the shader trusts the row.
try:
Transform(rotation=(2.0, 0.0, 0.0, 0.0))
except ValueError as error:
assert "not a unit quaternion" in str(error)
The scalar is last — (x, y, z, w). A scalar-first quaternion is usually
unit, so it passes the check and rotates: (1.0, 0.0, 0.0, 0.0) written for the
identity is a half turn about x here. Build rotations with axis_angle and the
question does not arise.
Upload it
The table is an R32UI texture, NEAREST filtered, with the rows consecutive
and each row stride words. The texture is two-dimensional, because a single
row would cap the submesh count at whatever MAX_TEXTURE_SIZE a device
reports; a caller picks a width and a word's address wraps across texture rows:
from vcti.shader.transform import address, rows_needed, texel
word = address(1000, 8, STRIDE) # submesh 1000, first scale slot
assert word == 11_008
assert texel(word, 2048) == (768, 5)
assert rows_needed(5000, STRIDE, 2048) == 27
pack_table lays every submesh's row out in that order and pads the last
texture row, so what it returns is the texture itself — word a is the texel
texel(a, width), and a caller uploads it as-is at that width:
from vcti.shader.transform import pack_table
table = pack_table([None, moved, None], width=8)
assert len(table) == rows_needed(3, STRIDE, 8) * 8 == 40
assert table[address(1, 0, STRIDE)] == 1 # submesh 1's presence word
assert table[address(0, 0, STRIDE)] == 0 # submesh 0 never moved
A None entry is an unmoved submesh, so a client with a sparse set of
placements passes None for the rest. The result is a tuple of ints, which a
caller with an array library wraps before upload — this package has no array
dependency to return something narrower.
Pass the width and stride as u_transformLutWidth and u_transformLutStride.
The stride is eleven today; it is a uniform so that a table a later release
widens still reads correctly in a shader built against this one.
What a build step binds
from vcti.shader.transform import vertex_attributes, vertex_uniforms
(attribute,) = vertex_attributes()
assert (attribute.name, attribute.type, attribute.semantic) == (
"a_transformId", "int", "transform-id"
)
assert [u.name for u in vertex_uniforms()] == ["u_transformLutWidth", "u_transformLutStride"]
The attribute is named for the feature, not for what the id counts. The client binds whichever id buffer it likes to it, and where another feature is keyed by the same id, binds the same buffer to that feature's attribute too.
The table is declared too, as a TableSpec, which carries the format it has to
arrive in:
from vcti.shader.transform import vertex_tables
(table,) = vertex_tables()
assert (table.name, table.type) == ("u_transformLut", "Texture2D<uint4>")
assert (table.format, table.semantic) == ("r32ui", "transform")
A caller binds it by the semantic, not the name: cross-compiling to GLSL ES pairs the texture with a dummy sampler and names the combination itself, so the declared name reaches the shader only as a fragment of a generated identifier. The width and the stride are not part of it — addressing is this feature's own business. See docs/design.md.
Select a pipeline
One tag for the behaviour and one naming the table encoding:
from vcti.shader.transform import DEFINITION
assert DEFINITION.id == "transform"
assert DEFINITION.role.value == "vertex"
assert set(DEFINITION.capabilities) == {"transform", "transform-lut-r32ui"}
assert DEFINITION.slang_modules == ("transform.slang", "transform_r32ui.slang")
The row
| Slot | Holds |
|---|---|
| 0 | present — 0 for the identity, 1 for a transform in slots 1-10 |
| 1-3 | translation x, y, z as float32 bits |
| 4-7 | rotation quaternion x, y, z, w as float32 bits, scalar last |
| 8-10 | per-axis scale x, y, z as float32 bits, every component positive |
Applied as rotate(q, scale ⊙ p) + t to a position and
normalize(rotate(q, n / scale)) to a normal. Floats are recovered in the
shader by bit reinterpretation, which is exact: what a row stores, the shader
reads back unchanged.
Getting a value into a row is a narrowing conversion, though — Python floats
are float64 and a slot is float32, so a row holds the nearest float32 to
what it was given. Every component must be a finite one: Transform refuses an
infinity, a NaN, or a value past FLOAT32_MAX, because a row has no bits for
the last and a vertex placed by either of the first two has an undefined
position at rasterization.
A scale is checked as the row stores it. A positive float64 such as 1e-50
narrows to zero, and a scale of zero is what the positivity rule exists to
refuse, so the floor is FLOAT32_MIN_NORMAL — the smallest normal float32,
because a subnormal one may be flushed to zero by the device even though the
mirror keeps it.
API surface
| Name | What it is |
|---|---|
DEFINITION |
the ShaderDefinition a build step imports to compose this feature |
SLANG_DIR, SLANG_MODULES |
the installed Slang directory, and the modules in it |
ACCESSOR_MODULE, FETCH_MODULE |
the two module names, the second derived from the encoding |
Transform, axis_angle, rotate |
the placement, and the math the shader mirrors |
Vector3, Quaternion |
the tuple aliases the placement is written in |
IDENTITY_ROTATION, IDENTITY_SCALE |
what is_identity compares against |
Transform.about_pivot |
the same placement with its scale and rotation about a pivot |
ROTATION_TOLERANCE |
how far a rotation may sit from unit before Transform refuses it |
pack_row, unpack_row, is_present |
build a row, read one the way the shader does |
pack_table |
every row laid out in address order, padded to fill the texture |
PRESENT, TRANSLATION, ROTATION, SCALE, STRIDE |
the slot assignment |
address, texel, rows_needed |
the addressing arithmetic |
float_to_bits, bits_to_float |
the bit reinterpretation a client in another language reproduces |
FLOAT32_MAX, FLOAT32_MIN_NORMAL |
the largest value a slot holds, and the floor on a scale |
vertex_attributes, vertex_uniforms, vertex_tables |
what a build step binds |
ATTRIBUTE, WIDTH, STRIDE_UNIFORM, TABLE |
the specs themselves |
CAPABILITY, ENCODING, ENCODING_CAPABILITY |
the tags, and the shipped texture format |
Dependencies
vcti-shader-base is the only runtime dependency — declaring a feature is pure
data. vcti-shader-compiler>=4.0.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 |
| Size, upload and mutate a table | docs/patterns.md |
| Understand the table contract and the decisions behind it | docs/design.md |
| Navigate or modify the source, including the Slang | docs/source-guide.md |
The full API reference is generated from the source docstrings and published in the unified VCollab docs.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file vcti_shader_transform-1.1.0.tar.gz.
File metadata
- Download URL: vcti_shader_transform-1.1.0.tar.gz
- Upload date:
- Size: 61.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c91889cabe78dc6c1db4c5197dd2636e54a6d281e26b0241273a6f725bedad60
|
|
| MD5 |
b6546260e98add49b67dba1b2d4ebe78
|
|
| BLAKE2b-256 |
6b3e7d4c19543bb0c2cbb9a99a64a4629f79dc4691abb476990c7dc83ce7e3d4
|
Provenance
The following attestation bundles were made for vcti_shader_transform-1.1.0.tar.gz:
Publisher:
release.yml on vcollab/vcti-python-shader-transform
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
vcti_shader_transform-1.1.0.tar.gz -
Subject digest:
c91889cabe78dc6c1db4c5197dd2636e54a6d281e26b0241273a6f725bedad60 - Sigstore transparency entry: 2854526363
- Sigstore integration time:
-
Permalink:
vcollab/vcti-python-shader-transform@47816ad0edfd4fbb62b32889ce5dfb26aa53826a -
Branch / Tag:
refs/tags/v1.1.0 - Owner: https://github.com/vcollab
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@47816ad0edfd4fbb62b32889ce5dfb26aa53826a -
Trigger Event:
push
-
Statement type:
File details
Details for the file vcti_shader_transform-1.1.0-py3-none-any.whl.
File metadata
- Download URL: vcti_shader_transform-1.1.0-py3-none-any.whl
- Upload date:
- Size: 27.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0b22e732b0ddf2afc5ee71022b9871186904fc3b940145c7d82e83b3312b1961
|
|
| MD5 |
a3af36ee7826233cdc64fcd9fe4e945c
|
|
| BLAKE2b-256 |
ffaacb0e81245d2813ebcf73260fdf909c6b8e91ecdbd5bbcbf88da3f84bb32e
|
Provenance
The following attestation bundles were made for vcti_shader_transform-1.1.0-py3-none-any.whl:
Publisher:
release.yml on vcollab/vcti-python-shader-transform
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
vcti_shader_transform-1.1.0-py3-none-any.whl -
Subject digest:
0b22e732b0ddf2afc5ee71022b9871186904fc3b940145c7d82e83b3312b1961 - Sigstore transparency entry: 2854526400
- Sigstore integration time:
-
Permalink:
vcollab/vcti-python-shader-transform@47816ad0edfd4fbb62b32889ce5dfb26aa53826a -
Branch / Tag:
refs/tags/v1.1.0 - Owner: https://github.com/vcollab
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@47816ad0edfd4fbb62b32889ce5dfb26aa53826a -
Trigger Event:
push
-
Statement type: