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.0.0

In pyproject.toml dependencies

dependencies = [
    "vcti-shader-transform>=1.0.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 sampler itself is not in there — a texture input cannot yet be expressed in vcti-shader-base, so it is contract rather than spec. 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, ATTRIBUTE, WIDTH, STRIDE_UNIFORM what a build step binds
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.0.0.tar.gz (59.1 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.0.0-py3-none-any.whl (26.4 kB view details)

Uploaded Python 3

File details

Details for the file vcti_shader_transform-1.0.0.tar.gz.

File metadata

  • Download URL: vcti_shader_transform-1.0.0.tar.gz
  • Upload date:
  • Size: 59.1 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.0.0.tar.gz
Algorithm Hash digest
SHA256 d0422f2d594a055ef14c800ef9d84be73d160918040aa43cefb4c06c093d1ed1
MD5 c1c2704864bdd52b65fb4b4fbe569f8a
BLAKE2b-256 f43dfa171532f756101652a07a308ad23725dbda2c9bb66a048dbc2730d8ecba

See more details on using hashes here.

Provenance

The following attestation bundles were made for vcti_shader_transform-1.0.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.0.0-py3-none-any.whl.

File metadata

File hashes

Hashes for vcti_shader_transform-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 b6b3098b793438063f142082170ecebdaaf8567d93d1723c2e0d1bc9695cc2ed
MD5 2c07ae3685d530270134c5c9cf100e3f
BLAKE2b-256 57dc8c8ccd58cecdb03bc0e5b8184625ad9c45bd02872e7229eeca170cc514d7

See more details on using hashes here.

Provenance

The following attestation bundles were made for vcti_shader_transform-1.0.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

1.1.0

2 files

This release

1.0.0 This release

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