Repository: software-mansion/TypeGPU
Pull request #1478 (2025-07-10 17:11:46)

feat: invariant attribute
Implements `@invariant` attribute support.


Patch:
diff --git a/packages/typegpu/src/builtin.ts b/packages/typegpu/src/builtin.ts
index 3bd55ad1..d91541c6 100644
--- a/packages/typegpu/src/builtin.ts
+++ b/packages/typegpu/src/builtin.ts
@@ -57,13 +57,13 @@ export type BuiltinSubgroupSize = Decorated<U32, [Builtin<'subgroup_size'>]>;
 
 function defineBuiltin<T extends Decorated | LooseDecorated>(
   dataType: AnyWgslData,
-  value: T['attribs'][0] extends { value: infer TValue } ? TValue : never,
+  value: T['attribs'][0] extends { params: [infer TValue] } ? TValue : never,
 ): T {
   return attribute(dataType, {
     [$internal]: true,
     type: '@builtin',
     // biome-ignore lint/suspicious/noExplicitAny: it's fine
-    value: value as any,
+    params: [value as any],
   }) as T;
 }
 
diff --git a/packages/typegpu/src/data/attributes.ts b/packages/typegpu/src/data/attributes.ts
index fee5c497..db4931f7 100644
--- a/packages/typegpu/src/data/attributes.ts
+++ b/packages/typegpu/src/data/attributes.ts
@@ -31,6 +31,7 @@ import {
   type FlatInterpolationType,
   type Interpolate,
   type InterpolationType,
+  type Invariant,
   isAlignAttrib,
   isBuiltinAttrib,
   isDecorated,
@@ -40,6 +41,7 @@ import {
   type PerspectiveOrLinearInterpolatableData,
   type PerspectiveOrLinearInterpolationType,
   type Size,
+  type Vec4f,
   type WgslTypeLiteral,
 } from './wgslTypes.ts';
 
@@ -75,6 +77,7 @@ export type AnyAttribute<
   | Size<number>
   | Location<number>
   | Interpolate<InterpolationType>
+  | Invariant
   | AllowedBuiltins;
 
 export type ExtractAttributes<T> = T extends {
@@ -167,7 +170,7 @@ export function align<TAlign extends number, TData extends AnyData>(
   return attribute(data, {
     [$internal]: true,
     type: '@align',
-    value: alignment,
+    params: [alignment],
     // biome-ignore lint/suspicious/noExplicitAny: <tired of lying to types>
   }) as any;
 }
@@ -191,7 +194,7 @@ export function size<TSize extends number, TData extends AnyData>(
   return attribute(data, {
     [$internal]: true,
     type: '@size',
-    value: size,
+    params: [size],
     // biome-ignore lint/suspicious/noExplicitAny: <tired of lying to types>
   }) as any;
 }
@@ -216,7 +219,7 @@ export function location<TLocation extends number, TData extends AnyData>(
   return attribute(data, {
     [$internal]: true,
     type: '@location',
-    value: location,
+    params: [location],
     // biome-ignore lint/suspicious/noExplicitAny: <tired of lying to types>
   }) as any;
 }
@@ -279,7 +282,50 @@ export function interpolate<
   return attribute(data, {
     [$internal]: true,
     type: '@interpolate',
-    value: interpolationType,
+    params: [interpolationType],
+    // biome-ignore lint/suspicious/noExplicitAny: <tired of lying to types>
+  }) as any;
+}
+
+/**
+ * Marks a position built-in output value as invariant in vertex shaders.
+ * If the data and control flow match for two position outputs in different
+ * entry points, then the result values are guaranteed to be the same.
+ *
+ * Must only be applied to the position built-in value.
+ *
+ * @example
+ * const VertexOutput = {
+ *   pos: d.invariant(d.builtin.position),
+ * };
+ *
+ * @param data The position built-in data-type to mark as invariant.
+ */
+export function invariant(
+  data: Decorated<Vec4f, [Builtin<'position'>]>,
+): Decorated<Vec4f, [Builtin<'position'>, Invariant]> {
+  // Validate that invariant is only applied to position built-in
+  if (!isBuiltin(data)) {
+    throw new Error(
+      'The @invariant attribute must only be applied to the position built-in value.',
+    );
+  }
+
+  // Find the builtin attribute to check if it's position
+  const builtinAttrib = (isDecorated(data) || isLooseDecorated(data))
+    ? data.attribs.find(isBuiltinAttrib)
+    : undefined;
+
+  if (!builtinAttrib || builtinAttrib.params[0] !== 'position') {
+    throw new Error(
+      'The @invariant attribute must only be applied to the position built-in value.',
+    );
+  }
+
+  return attribute(data, {
+    [$internal]: true,
+    type: '@invariant',
+    params: [],
     // biome-ignore lint/suspicious/noExplicitAny: <tired of lying to types>
   }) as any;
 }
@@ -301,7 +347,12 @@ export function getAttributesString<T extends BaseData>(field: T): string {
   }
 
   return (field.attribs as AnyAttribute[])
-    .map((attrib) => `${attrib.type}(${attrib.value}) `)
+    .map((attrib) => {
+      if (attrib.params.length === 0) {
+        return `${attrib.type} `;
+      }
+      return `${attrib.type}(${attrib.params.join(', ')}) `;
+    })
     .join('');
 }
 
@@ -320,8 +371,8 @@ class BaseDecoratedImpl<TInner extends BaseData, TAttribs extends unknown[]> {
     public readonly inner: TInner,
     public readonly attribs: TAttribs,
   ) {
-    const alignAttrib = attribs.find(isAlignAttrib)?.value;
-    const sizeAttrib = attribs.find(isSizeAttrib)?.value;
+    const alignAttrib = attribs.find(isAlignAttrib)?.params[0];
+    const sizeAttrib = attribs.find(isSizeAttrib)?.params[0];
 
     if (alignAttrib !== undefined) {
       if (alignAttrib <= 0) {
diff --git a/packages/typegpu/src/data/dataTypes.ts b/packages/typegpu/src/data/dataTypes.ts
index 04648b7e..c4b57420 100644
--- a/packages/typegpu/src/data/dataTypes.ts
+++ b/packages/typegpu/src/data/dataTypes.ts
@@ -159,19 +159,19 @@ export function isLooseDecorated<T extends LooseDecorated>(
 export function getCustomAlignment(data: wgsl.BaseData): number | undefined {
   return (data as unknown as wgsl.Decorated | LooseDecorated).attribs?.find(
     wgsl.isAlignAttrib,
-  )?.value;
+  )?.params[0];
 }
 
 export function getCustomSize(data: wgsl.BaseData): number | undefined {
   return (data as unknown as wgsl.Decorated | LooseDecorated).attribs?.find(
     wgsl.isSizeAttrib,
-  )?.value;
+  )?.params[0];
 }
 
 export function getCustomLocation(data: wgsl.BaseData): number | undefined {
   return (data as unknown as wgsl.Decorated | LooseDecorated).attribs?.find(
     wgsl.isLocationAttrib,
-  )?.value;
+  )?.params[0];
 }
 
 export function isData(value: unknown): value is AnyData {
diff --git a/packages/typegpu/src/data/index.ts b/packages/typegpu/src/data/index.ts
index 8103c1e0..fcab634c 100644
--- a/packages/typegpu/src/data/index.ts
+++ b/packages/typegpu/src/data/index.ts
@@ -117,6 +117,7 @@ export {
   type AnyAttribute,
   type HasCustomLocation,
   interpolate,
+  invariant,
   type IsBuiltin,
   isBuiltin,
   location,
diff --git a/packages/typegpu/src/data/wgslTypes.ts b/packages/typegpu/src/data/wgslTypes.ts
index c2e0777c..96c6e2b8 100644
--- a/packages/typegpu/src/data/wgslTypes.ts
+++ b/packages/typegpu/src/data/wgslTypes.ts
@@ -1236,19 +1236,19 @@ export interface atomicI32 {
 export interface Align<T extends number> {
   readonly [$internal]: true;
   readonly type: '@align';
-  readonly value: T;
+  readonly params: [T];
 }
 
 export interface Size<T extends number> {
   readonly [$internal]: true;
   readonly type: '@size';
-  readonly value: T;
+  readonly params: [T];
 }
 
 export interface Location<T extends number = number> {
   readonly [$internal]: true;
   readonly type: '@location';
-  readonly value: T;
+  readonly params: [T];
 }
 
 export type PerspectiveOrLinearInterpolationType = `${
@@ -1262,13 +1262,19 @@ export type InterpolationType =
 export interface Interpolate<T extends InterpolationType = InterpolationType> {
   readonly [$internal]: true;
   readonly type: '@interpolate';
-  readonly value: T;
+  readonly params: [T];
 }
 
 export interface Builtin<T extends string> {
   readonly [$internal]: true;
   readonly type: '@builtin';
-  readonly value: T;
+  readonly params: [T];
+}
+
+export interface Invariant {
+  readonly [$internal]: true;
+  readonly type: '@invariant';
+  readonly params: [];
 }
 
 export interface Decorated<
@@ -1584,6 +1590,12 @@ export function isBuiltinAttrib<T extends Builtin<string>>(
   return (value as T)?.[$internal] && (value as T)?.type === '@builtin';
 }
 
+export function isInvariantAttrib<T extends Invariant>(
+  value: unknown | T,
+): value is T {
+  return (value as T)?.[$internal] && (value as T)?.type === '@invariant';
+}
+
 export function isDecorated<T extends Decorated>(
   value: unknown | T,
 ): value is T {


Test patch:
diff --git a/packages/typegpu/tests/invariant.test.ts b/packages/typegpu/tests/invariant.test.ts
new file mode 100644
index 00000000..41284fc5
--- /dev/null
+++ b/packages/typegpu/tests/invariant.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest';
+import * as d from '../src/data/index.ts';
+import tgpu from '../src/index.ts';
+
+describe('invariant', () => {
+  it('adds @invariant attribute to position builtin', () => {
+    const s1 = d.struct({
+      position: d.invariant(d.builtin.position),
+    });
+
+    const resolved = tgpu.resolve({
+      externals: { s1 },
+      names: 'strict',
+    });
+
+    expect(resolved).toContain(
+      '@invariant @builtin(position) position: vec4f',
+    );
+  });
+
+  it('throws error when applied to non-position builtin', () => {
+    expect(() => {
+      // @ts-expect-error - Testing error case with invalid builtin
+      d.invariant(d.builtin.vertexIndex);
+    }).toThrow(
+      'The @invariant attribute must only be applied to the position built-in value.',
+    );
+  });
+
+  it('throws error when applied to non-builtin data', () => {
+    expect(() => {
+      // @ts-expect-error - Testing error case with non-builtin data
+      d.invariant(d.f32);
+    }).toThrow(
+      'The @invariant attribute must only be applied to the position built-in value.',
+    );
+  });
+
+  it('can be used in vertex shader output', () => {
+    const VertexOutput = d.struct({
+      pos: d.invariant(d.builtin.position),
+      color: d.vec4f,
+    });
+
+    const resolved = tgpu.resolve({
+      externals: { VertexOutput },
+      names: 'strict',
+    });
+
+    expect(resolved).toContain('@invariant @builtin(position) pos: vec4f');
+    expect(resolved).toContain('color: vec4f');
+  });
+});