Skip to main content

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

vcti_shader_transform-1.1.0.tar.gz (61.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

vcti_shader_transform-1.1.0-py3-none-any.whl (27.5 kB view details)

Uploaded Python 3

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

Hashes for vcti_shader_transform-1.1.0.tar.gz
Algorithm Hash digest
SHA256 c91889cabe78dc6c1db4c5197dd2636e54a6d281e26b0241273a6f725bedad60
MD5 b6546260e98add49b67dba1b2d4ebe78
BLAKE2b-256 6b3e7d4c19543bb0c2cbb9a99a64a4629f79dc4691abb476990c7dc83ce7e3d4

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file vcti_shader_transform-1.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for vcti_shader_transform-1.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 0b22e732b0ddf2afc5ee71022b9871186904fc3b940145c7d82e83b3312b1961
MD5 a3af36ee7826233cdc64fcd9fe4e945c
BLAKE2b-256 ffaacb0e81245d2813ebcf73260fdf909c6b8e91ecdbd5bbcbf88da3f84bb32e

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

1.1.0 This release

2 files

1.0.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page