Skip to content

changed(rkyv)!: Archive glam types to dedicated portable archived types - #766

Draft
MarijnS95 wants to merge 5 commits into
bitshifter:mainfrom
MarijnS95:rkyv-portable-archived-types
Draft

MarijnS95 wants to merge 5 commits into
bitshifter:mainfrom
MarijnS95:rkyv-portable-archived-types

Conversation

@MarijnS95

@MarijnS95 MarijnS95 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Archive glam types to dedicated portable archived types

Every glam type currently archives to itself (type Archived = Vec3), so the archived representation inherits three properties an archive format should not have:

  • Native endianness, so an archive written on a little-endian machine cannot be read on a big-endian one.
  • SIMD-backend-dependent layout. Vec4 is align_of == 16 on SSE2/NEON and 4 under scalar-math, which shifts field offsets in any struct embedding one — a scalar-math build cannot read an archive written by a SIMD build of the same version.
  • Padding copied in uninitialised. resolve does out.write(*self), so any padding in the native type lands in the archive uninitialised — and every backend has some type with padding, just not the same one. Under scalar-math it is Vec3A/Mat3A/Affine3A, which are #[repr(C, align(16))] over three f32, so four of their sixteen bytes are padding. Under SSE2/NEON those are fine — their fourth lane is real and initialised — but Affine2 is 32 bytes there against 24 bytes of fields, because Mat2 becomes 16 byte aligned, leaving 8 bytes of tail padding. Besides being UB, this makes archives non-reproducible, which matters to anyone byte-comparing them.

The fix

Give each type an Archived* struct built from rkyv's own archived primitives, as rkyv's derive already does for user structs:

#[derive(Clone, Copy, Debug, PartialEq)]
#[repr(transparent)]
pub struct ArchivedVec3(pub [Archived<f32>; 3]);

rkyv::Archived<f32> resolves to f32_le/f32_ule/f32_be/f32_ube per rkyv's own unaligned and big_endian features, so one definition covers every configuration and no new glam feature is needed — a crate cannot cfg on another crate's features, so deferring to these aliases is the only mechanism that works.

Conversions use to_array/to_cols_array, which behave identically on every backend, so the archived layout no longer depends on how glam was compiled. Single-byte vectors keep byte elements (Archived<u8> is u8). The archived types are storage only: no #[repr(align(..))], no arithmetic — Deserialize recovers the native type and its alignment.

Effects

type native (SIMD) native (scalar-math) archived, both
Vec3A align 16, size 16 align 16, size 16 align 4, size 12
Vec4 align 16, size 16 align 4, size 16 align 4, size 16
Mat3A align 16, size 48 align 16, size 48 align 4, size 36
Affine3A align 16, size 64 align 16, size 64 align 4, size 48

With rkyv's unaligned feature all of these are align 1, which is the payoff: archives can be read in place from buffers with no alignment guarantee, such as mmap'd database records.

This breaks the archived format, and &ArchivedVec3 is no longer a free reference cast to &Vec3 — reads convert now. That cast was only valid when the caller controlled both alignment and endianness of the buffer, which a library cannot assume on a consumer's behalf; for Vec3A, a 16-aligned SIMD load straight out of an archive was never guaranteed. Happy to put the old behaviour behind an opt-in feature if that is wanted.

To keep that from being painful, the archived types carry From conversions in both directions, and bytemuck::Pod/Zeroable under the existing bytemuck feature. The latter matters more than it looks: archived data is what gets uploaded to a GPU or written to a file, and while Archived = Self that worked through the native type's own Pod derive. The derive is unusable on the archived types — it would require the element type to be Pod, which rend only is with its bytemuck-1 feature, not reachable through rkyv — so these are hand-written impls with the padding and bit-pattern reasoning spelled out. I found both of these by patching this branch into a consumer that streams archived glam buffers straight to the GPU; without them the breakage is much wider than the format change itself.

Note on the rkyv 0.8 port

type Archived = $type predates the 0.8 upgrade, so all three problems are inherited. rkyv 0.7 had no way to state them: resolve wrote through a raw *mut, making the padding copy a latent hazard rather than a claim. The 0.8 port added two unsafe impls that assert the opposite — Portable ("layout that is the same on all targets", which the first two points deny) and NoUndef (absence of padding). Place::write is gated on NoUndef precisely to keep uninit bytes out of the buffer, so the guard rail was there and the impl asserted past it. glam's bytemuck support already draws this distinction correctly, deriving AnyBitPattern for scalar Vec3A and Pod only for the SIMD ones. Both impls hold unconditionally for the new types.

CheckBytes returning Ok(()) was sound throughout — the BVec bool vectors are deliberately not covered — but is now delegated to the underlying array so nothing is asserted by hand.

That does partly walk back #606, which dropped the validated-access branch from test_archive on the grounds that every bit pattern is valid for the primitives glam uses. That reasoning still holds for bit patterns, but rkyv::access also checks that the buffer satisfies the archived type's alignment, which is the property this PR is about — so the tests use it again when bytecheck is enabled, and fall back to unchecked access only when it is not.

COPY_OPTIMIZATION

glam never set Archive::COPY_OPTIMIZATION, so it defaulted to disable() and rkyv serialized a Vec<Vec3> element by element. This PR enables it wherever the archived form really is a copy of the native one, which is most types, so the common bulk cases (vertex positions, UVs, transform arrays) become a single memcpy — that also keeps this change from costing consumers throughput on the way past.

The condition is derived rather than hardcoded, since eligibility varies by backend:

CopyOptimization::enable_if(
    <$prim as Archive>::COPY_OPTIMIZATION.is_enabled()
        && size_of::<Self>() == size_of::<$archived>(),
)

The first half is rkyv's own MULTIBYTE_PRIMITIVES_ARE_TRIVIALLY_COPYABLE, false whenever the archive endianness and the target endianness disagree. The second is what rules out padding: the native type holds exactly $n primitives, so if it is no larger than $n of them there is nowhere for padding to hide. That covers Vec2, Vec3, Vec4, Quat, Mat2, Mat3, Mat4 and Affine3 in both backends, and lets Affine2 opt in under scalar-math while turning itself off under SSE2/NEON, with no per-backend cfg.

Byte-order identity is the one part not provable from sizes. glam implements to_array/to_cols_array as a transmute for the flat SIMD types and in declaration order for the composites, so it holds — and a test now asserts the archived bytes equal the native bytes for every type where the hint is on, so a future layout change fails the build rather than silently corrupting archives.

Open question: the A types

Vec3A/Mat3A/Affine3A cannot have it, and that is the one real trade-off here: a tight archived form (12 bytes, no uninit) rules the memcpy out forever, while archived-equals-native (16 bytes) keeps it available but copies the padding, which CopyOptimization::enable() forbids outright. Smaller archives seemed the better default, but happy to go the other way.

Giving scalar Vec3A an explicit fourth field — as the SIMD backends already have a real w lane — would get both, and let it derive Pod instead of AnyBitPattern, at the cost of changing the type itself rather than just its rkyv support.

Testing

cargo run -p ci passes, as do rkyv, rkyv,bytecheck, rkyv,scalar-math, rkyv,bytecheck,scalar-math and all numeric-type features. Four new tests: the archived alignment is pinned to Archived<f32> and the archived size to the exact element count, so a future repr(align) or padding regression fails the build instead of silently changing the format; the copy optimization is asserted to be on for the types that qualify in both backends and off for the three that never can; and test_archive now compares the archived bytes against the native bytes whenever the hint is enabled. I checked those last two are not vacuous by forcing the hint on, which fails on Affine2 at 32 against 24 bytes under SSE2. The test helper now uses the checked rkyv::access when bytecheck is on; the unchecked path remains only for builds without it, where the safety comment now names alignment as well as bit-pattern validity.

@MarijnS95
MarijnS95 force-pushed the rkyv-portable-archived-types branch from 886600e to 751034a Compare August 10, 2026 13:48
Comment thread src/lib.rs Outdated
Comment thread CHANGELOG.md Outdated
Comment thread CHANGELOG.md Outdated
Comment thread CHANGELOG.md Outdated
@bitshifter

Copy link
Copy Markdown
Owner

I'll look at this a bit, I want to get some other things in before making a breaking change.

@bitshifter
bitshifter force-pushed the rkyv-portable-archived-types branch from 751034a to f8a1769 Compare August 20, 2026 12:20
@bitshifter
bitshifter self-requested a review August 20, 2026 12:21
@bitshifter
bitshifter dismissed their stale review August 20, 2026 12:22

Addressed the things I wanted changed.

@bitshifter bitshifter changed the title Archive glam types to dedicated portable archived types changed(rkyv)!: Archive glam types to dedicated portable archived types Aug 20, 2026
@MarijnS95

Copy link
Copy Markdown
Contributor Author

I'll look at this a bit, I want to get some other things in before making a breaking change.

There are still a bunch of things I'll be pushing to this PR, but I am currently on leave and will get back to that within next week.

@bitshifter

Copy link
Copy Markdown
Owner

There are still a bunch of things I'll be pushing to this PR, but I am currently on leave and will get back to that within next week.

Ah OK, I will change it to a draft PR until it's ready. Let me know what else you wanted to do when you're back. This PR reminds me that #122 is still an issue for some use cases and perhaps I should revisit options for solving that.

@bitshifter
bitshifter marked this pull request as draft August 20, 2026 20:53
@bitshifter
bitshifter force-pushed the rkyv-portable-archived-types branch from f8a1769 to 4e19e3c Compare August 21, 2026 10:25
@bitshifter

Copy link
Copy Markdown
Owner

I've fixed #122 so Vec3A's padding is initialised now which means Vec3A/Mat3A/Affine3A padding is also initialised. Affine2 still contains uninitialised padding. Rebased your PR to latest.

MarijnS95 and others added 5 commits August 31, 2026 19:31
Every glam type archived to itself, so the archived representation inherited
the native type's endianness, alignment and SIMD-backend-dependent layout.
Archives were therefore not portable across endianness, nor between glam's own
scalar and SIMD backends, and the scalar `Vec3A`/`Mat3A`/`Affine3A` padding was
copied into output buffers uninitialised.

Give each type an `Archived*` struct built from rkyv's own archived primitives
instead. `rkyv::Archived<f32>` already resolves according to rkyv's `unaligned`
and `big_endian` features, so one definition covers every configuration without
adding a glam feature, and the archived layout no longer depends on which
backend glam was compiled with.

This also corrects two `unsafe impl`s added in the rkyv 0.8 port: `Portable`
claimed a layout identical on all targets, and `NoUndef` claimed an absence of
padding that scalar `Vec3A` does not have. Both hold for the new types.
The archived types introduced alongside this are a byte for byte copy of
their native form for every type without padding, which lets rkyv
serialize slices of them with a single memcpy instead of resolving each
element. glam never set COPY_OPTIMIZATION, so this was left on the table
before as well.

Eligibility is derived from the element type's own hint and a size
comparison rather than hardcoded, because it differs per backend:
Affine2 has tail padding under SSE2 and NEON but not under scalar-math.
Archived data is what gets uploaded to a GPU or mapped from a file, so the
archived types need the same bytemuck impls the native ones have. The
derive cannot be used: it would require the element type to be Pod, which
rend only is when built with its bytemuck-1 feature, and that is not
reachable through rkyv.

Also adds From conversions in both directions. Reading a single archived
value previously meant either a reference cast, which this branch makes
unsound, or rkyv::deserialize with a deserializer and an error type for a
conversion that cannot fail.
@bitshifter
bitshifter force-pushed the rkyv-portable-archived-types branch from 4e19e3c to 59d96bd Compare August 31, 2026 07:32
@bitshifter

Copy link
Copy Markdown
Owner

@MarijnS95 did you still have changes you wanted to make in this PR? I've been rebasing with main to keep it up to date but I can stop doing that if it's annoying. It's just the changelog that's getting conflicts.

I have another breaking change that I might want to make so I'd try an bundle it with this to minimise churn.

Also I added a AI_POLICY.md to glam, as part of that I removed the claude contribution message and it would be good if the PR description was simplified, it's quite verbose and hard to follow for me at the moment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants