From 811058a65cc98524beaae86716ce2c40dd6cdccb Mon Sep 17 00:00:00 2001 From: huymobile Date: Mon, 24 Aug 2026 13:03:20 +0700 Subject: [PATCH 1/6] impr: auto-interpolate integer varyings --- packages/typegpu/src/core/function/autoIO.ts | 14 +++- .../typegpu/src/core/function/ioSchema.ts | 66 +++++++++++++----- .../src/core/function/tgpuFragmentFn.ts | 6 +- .../typegpu/src/core/function/tgpuVertexFn.ts | 8 ++- packages/typegpu/src/data/autoStruct.ts | 4 ++ packages/typegpu/tests/renderPipeline.test.ts | 69 +++++++++++++++++-- 6 files changed, 140 insertions(+), 27 deletions(-) diff --git a/packages/typegpu/src/core/function/autoIO.ts b/packages/typegpu/src/core/function/autoIO.ts index b35a092b1a..5d0b2defe6 100644 --- a/packages/typegpu/src/core/function/autoIO.ts +++ b/packages/typegpu/src/core/function/autoIO.ts @@ -86,7 +86,12 @@ export class AutoFragmentFn implements SelfResolvable { setName(impl, 'fragmentFn'); } this.#core = createFnCore(impl, 'fragment'); - this.autoIn = new AutoStruct({ ...builtinFragmentIn, ...varyings }, undefined, locations); + this.autoIn = new AutoStruct( + { ...builtinFragmentIn, ...varyings }, + undefined, + locations, + /* autoInterpolateIntegers */ true, + ); setName(this.autoIn, 'FragmentIn'); this.autoOut = new AutoStruct(builtinFragmentOut, vec4f); setName(this.autoOut, 'FragmentOut'); @@ -134,7 +139,12 @@ export class AutoVertexFn implements SelfResolvable { this.#core = createFnCore(impl, 'vertex'); this.autoIn = new AutoStruct({ ...builtinVertexIn, ...attribs }, undefined, locations); setName(this.autoIn, 'VertexIn'); - this.autoOut = new AutoStruct(builtinVertexOut, undefined); + this.autoOut = new AutoStruct( + builtinVertexOut, + undefined, + undefined, + /* autoInterpolateIntegers */ true, + ); setName(this.autoOut, 'VertexOut'); } diff --git a/packages/typegpu/src/core/function/ioSchema.ts b/packages/typegpu/src/core/function/ioSchema.ts index 9d3fe80726..152e165aa4 100644 --- a/packages/typegpu/src/core/function/ioSchema.ts +++ b/packages/typegpu/src/core/function/ioSchema.ts @@ -2,12 +2,23 @@ import { type Decorate, type HasCustomLocation, type IsBuiltin, + interpolate, location, } from '../../data/attributes.ts'; import { isBuiltin } from '../../data/attributes.ts'; import { getCustomLocation, isData } from '../../data/dataTypes.ts'; import { INTERNAL_createStruct } from '../../data/struct.ts'; -import { type BaseData, isVoid, type Location, type WgslStruct } from '../../data/wgslTypes.ts'; +import { + type BaseData, + type FlatInterpolatableData, + isDecorated, + isInteger, + isIntegerVec, + isInterpolateAttrib, + isVoid, + type Location, + type WgslStruct, +} from '../../data/wgslTypes.ts'; import type { SeparatedEntryArgs } from './fnTypes.ts'; export type WithLocations> = { @@ -31,6 +42,7 @@ export type IOLayoutToSchema = T extends BaseData export function withLocations( members: Record | undefined, locations: Record = {}, + autoInterpolateIntegers = false, ): Record { let nextLocation = 0; const usedCustomLocations = new Set(); @@ -47,7 +59,12 @@ export function withLocations( usedCustomLocations.add(customLocation); } - return [key, member] as const; + return [ + key, + autoInterpolateIntegers && !isBuiltin(member) + ? withFlatInterpolationForInteger(member) + : member, + ] as const; }) .map(([key, member]) => { if (isBuiltin(member)) { @@ -76,6 +93,7 @@ export function withLocations( export function separateBuiltins( schema: Record, locations: Record = {}, + autoInterpolateIntegers = false, ): SeparatedEntryArgs { const positionalArgs: SeparatedEntryArgs['positionalArgs'] = []; const dataFields: Record = {}; @@ -90,7 +108,10 @@ export function separateBuiltins( const dataSchema = Object.keys(dataFields).length > 0 - ? INTERNAL_createStruct(withLocations(dataFields, locations), /* isAbstruct */ false) + ? INTERNAL_createStruct( + withLocations(dataFields, locations, autoInterpolateIntegers), + /* isAbstruct */ false, + ) : undefined; return { dataSchema, positionalArgs }; @@ -105,19 +126,32 @@ export function separateAllAsPositional(schema: Record): Separ export function createIoSchema>( layout: T, locations: Record = {}, + autoInterpolateIntegers = false, ) { - return ( - isData(layout) - ? isVoid(layout) - ? layout - : isBuiltin(layout) - ? layout - : getCustomLocation(layout) !== undefined - ? layout - : location(0, layout) - : INTERNAL_createStruct( - withLocations(layout as Record, locations), - /* isAbstruct */ false, - ) + if (isData(layout)) { + if (isVoid(layout) || isBuiltin(layout)) { + return layout as unknown as IOLayoutToSchema; + } + + const data = autoInterpolateIntegers ? withFlatInterpolationForInteger(layout) : layout; + return ( + getCustomLocation(data) !== undefined ? data : location(0, data) + ) as IOLayoutToSchema; + } + + return INTERNAL_createStruct( + withLocations(layout as Record, locations, autoInterpolateIntegers), + /* isAbstruct */ false, ) as IOLayoutToSchema; } + +function withFlatInterpolationForInteger(data: BaseData): BaseData { + if (isDecorated(data) && data.attribs.some(isInterpolateAttrib)) { + return data; + } + + const inner = isDecorated(data) ? data.inner : data; + return isInteger(inner) || isIntegerVec(inner) + ? interpolate('flat', data as FlatInterpolatableData) + : data; +} diff --git a/packages/typegpu/src/core/function/tgpuFragmentFn.ts b/packages/typegpu/src/core/function/tgpuFragmentFn.ts index 7409cbdbb5..851000e644 100644 --- a/packages/typegpu/src/core/function/tgpuFragmentFn.ts +++ b/packages/typegpu/src/core/function/tgpuFragmentFn.ts @@ -209,7 +209,11 @@ function createFragmentFn( }, [$resolve](ctx: ResolutionCtx): ResolvedSnippet { - const entryInput = separateBuiltins(shell.in ?? {}, ctx.varyingLocations ?? {}); + const entryInput = separateBuiltins( + shell.in ?? {}, + ctx.varyingLocations ?? {}, + /* autoInterpolateIntegers */ true, + ); if (entryInput.dataSchema && isNamable(entryInput.dataSchema)) { entryInput.dataSchema.$name(`${getName(this) ?? ''}_Input`); diff --git a/packages/typegpu/src/core/function/tgpuVertexFn.ts b/packages/typegpu/src/core/function/tgpuVertexFn.ts index 68b7bee7d3..da619b4df1 100644 --- a/packages/typegpu/src/core/function/tgpuVertexFn.ts +++ b/packages/typegpu/src/core/function/tgpuVertexFn.ts @@ -177,9 +177,11 @@ function createVertexFn( }, [$resolve](ctx: ResolutionCtx): ResolvedSnippet { - const outputWithLocation = createIoSchema(shell.out, ctx.varyingLocations).$name( - `${getName(this) ?? ''}_Output`, - ); + const outputWithLocation = createIoSchema( + shell.out, + ctx.varyingLocations, + /* autoInterpolateIntegers */ true, + ).$name(`${getName(this) ?? ''}_Output`); if (typeof implementation === 'string') { core.setExternals('out', { Out: outputWithLocation }); diff --git a/packages/typegpu/src/data/autoStruct.ts b/packages/typegpu/src/data/autoStruct.ts index d08186bad5..ee00d22d24 100644 --- a/packages/typegpu/src/data/autoStruct.ts +++ b/packages/typegpu/src/data/autoStruct.ts @@ -34,6 +34,7 @@ export class AutoStruct implements BaseData, SelfResolvable { #locations: Record | undefined; #cachedStruct: WgslStruct | undefined; #typeForExtraProps: BaseData | undefined; + readonly #autoInterpolateIntegers: boolean; static { AutoStruct.prototype[$internal] = {}; @@ -44,11 +45,13 @@ export class AutoStruct implements BaseData, SelfResolvable { validProps: Record, typeForExtraProps: BaseData | undefined, locations?: Record, + autoInterpolateIntegers = false, ) { this.#validProps = validProps; this.#typeForExtraProps = typeForExtraProps; this.#allocated = {}; this.#locations = locations; + this.#autoInterpolateIntegers = autoInterpolateIntegers; this.#usedWgslKeys = new Set(); } @@ -102,6 +105,7 @@ export class AutoStruct implements BaseData, SelfResolvable { }), ), this.#locations, + this.#autoInterpolateIntegers, ); const ownName = getName(this); // Passing the given name forward diff --git a/packages/typegpu/tests/renderPipeline.test.ts b/packages/typegpu/tests/renderPipeline.test.ts index df36265dbe..93035767c9 100644 --- a/packages/typegpu/tests/renderPipeline.test.ts +++ b/packages/typegpu/tests/renderPipeline.test.ts @@ -153,6 +153,65 @@ describe('render pipeline behavior', () => { }); describe('resolve', () => { + it('automatically uses flat interpolation for integer varyings', ({ root }) => { + const vertexMain = tgpu.vertexFn({ + out: { + count: d.u32, + coordinates: d.location(4, d.vec2i), + tagged: d.interpolate('flat, either', d.u32), + position: d.builtin.position, + }, + })`{ return Out(); }`; + + const fragmentMain = tgpu.fragmentFn({ + in: { + count: d.u32, + coordinates: d.location(4, d.vec2i), + tagged: d.interpolate('flat, either', d.u32), + }, + out: d.vec4f, + })(({ count, coordinates, tagged }) => { + 'use gpu'; + return d.vec4f(d.f32(count), d.f32(coordinates.x), d.f32(tagged), 1); + }); + + const pipeline = root.createRenderPipeline({ + vertex: vertexMain, + fragment: fragmentMain, + targets: { format: 'r8unorm' }, + }); + + const resolved = tgpu.resolve([pipeline]); + + expect(resolved.match(/@location\(0\) @interpolate\(flat\) count: u32/g)).toHaveLength(2); + expect( + resolved.match(/@interpolate\(flat\) @location\(4\) coordinates: vec2i/g), + ).toHaveLength(2); + expect( + resolved.match(/@location\(\d+\) @interpolate\(flat, either\) tagged: u32/g), + ).toHaveLength(2); + }); + + it('automatically uses flat interpolation for inferred integer varyings', ({ root }) => { + const pipeline = root.createRenderPipeline({ + vertex: () => { + 'use gpu'; + return { $position: d.vec4f(), count: d.u32(1) }; + }, + fragment: ({ count, $primitiveIndex }) => { + 'use gpu'; + return d.vec4f(d.f32(count + $primitiveIndex)); + }, + targets: { format: 'r8unorm' }, + }); + + const resolved = tgpu.resolve([pipeline]); + + expect(resolved.match(/@location\(0\) @interpolate\(flat\) count: u32/g)).toHaveLength(2); + expect(resolved).toContain('@builtin(primitive_index) primitiveIndex: u32'); + expect(resolved).not.toContain('@interpolate(flat) @builtin(primitive_index)'); + }); + it('resolves with correct locations when pairing up a vertex and a fragment function', ({ root, }) => { @@ -196,7 +255,7 @@ describe('render pipeline behavior', () => { @location(1) bar: vec3f, @location(0) baz: vec3f, @location(5) baz2: f32, - @location(3) baz3: u32, + @location(3) @interpolate(flat) baz3: u32, @builtin(position) pos: vec4f, } @@ -248,13 +307,13 @@ describe('render pipeline behavior', () => { @builtin(position) position: vec4f, @location(0) baz: vec3f, @location(5) baz2: f32, - @location(3) baz3: u32, + @location(3) @interpolate(flat) baz3: u32, } @vertex fn vertexMain() -> vertexMain_Output { return vertexMain_Output(); } struct fragmentMain_Input { - @location(3) baz3: u32, + @location(3) @interpolate(flat) baz3: u32, @location(1) bar: vec3f, @location(2) foo: vec3f, @location(5) baz2: f32, @@ -868,7 +927,7 @@ describe('root.createRenderPipeline', () => { expect(tgpu.resolve([pipeline])).toMatchInlineSnapshot(` "struct VertexOut { @builtin(position) position: vec4f, - @location(0) prop: i32, + @location(0) @interpolate(flat) prop: i32, } @vertex fn vertex() -> VertexOut { @@ -876,7 +935,7 @@ describe('root.createRenderPipeline', () => { } struct FragmentIn { - @location(0) prop: i32, + @location(0) @interpolate(flat) prop: i32, } @fragment fn fragment(_arg_0: FragmentIn) -> @location(0) vec4f { From 2139f362fe699bfba4e154bdbeeea9bcb811be6a Mon Sep 17 00:00:00 2001 From: huymobile Date: Mon, 24 Aug 2026 14:08:22 +0700 Subject: [PATCH 2/6] test: lock integer interpolation scope --- packages/typegpu/tests/renderPipeline.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/typegpu/tests/renderPipeline.test.ts b/packages/typegpu/tests/renderPipeline.test.ts index 93035767c9..bb39e3f004 100644 --- a/packages/typegpu/tests/renderPipeline.test.ts +++ b/packages/typegpu/tests/renderPipeline.test.ts @@ -212,6 +212,32 @@ describe('render pipeline behavior', () => { expect(resolved).not.toContain('@interpolate(flat) @builtin(primitive_index)'); }); + it('does not flat interpolate integer vertex inputs or fragment outputs', ({ root }) => { + const vertexMain = tgpu.vertexFn({ + in: { index: d.u32 }, + out: { position: d.builtin.position }, + })(({ index }) => { + 'use gpu'; + return { position: d.vec4f(d.f32(index), 0, 0, 1) }; + }); + + const fragmentMain = tgpu.fragmentFn({ out: d.vec4u })(() => d.vec4u(1)); + const pipeline = root.createRenderPipeline({ + vertex: vertexMain, + fragment: fragmentMain, + targets: { format: 'rgba8uint' }, + }); + + const resolved = tgpu.resolve([pipeline]); + + expect(resolved).toContain('@location(0) index: u32'); + expect(resolved).not.toContain('@location(0) @interpolate(flat) index: u32'); + expect(resolved).toContain('@fragment fn fragmentMain() -> @location(0) vec4u'); + expect(resolved).not.toContain( + '@fragment fn fragmentMain() -> @location(0) @interpolate(flat) vec4u', + ); + }); + it('resolves with correct locations when pairing up a vertex and a fragment function', ({ root, }) => { From 5f857da270160242b39f25b6c1efc47a3890640f Mon Sep 17 00:00:00 2001 From: Iwo Plaza Date: Fri, 2 Oct 2026 16:20:16 +0200 Subject: [PATCH 3/6] test: snapshot integer interpolation output Add inline snapshots to the integer interpolation regressions and cover resolving bare vertex/fragment functions. --- packages/typegpu/tests/renderPipeline.test.ts | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/packages/typegpu/tests/renderPipeline.test.ts b/packages/typegpu/tests/renderPipeline.test.ts index bb39e3f004..598628f248 100644 --- a/packages/typegpu/tests/renderPipeline.test.ts +++ b/packages/typegpu/tests/renderPipeline.test.ts @@ -190,6 +190,26 @@ describe('render pipeline behavior', () => { expect( resolved.match(/@location\(\d+\) @interpolate\(flat, either\) tagged: u32/g), ).toHaveLength(2); + expect(resolved).toMatchInlineSnapshot(` + "struct vertexMain_Output { + @location(0) @interpolate(flat) count: u32, + @interpolate(flat) @location(4) coordinates: vec2i, + @location(1) @interpolate(flat, either) tagged: u32, + @builtin(position) position: vec4f, + } + + @vertex fn vertexMain() -> vertexMain_Output { return vertexMain_Output(); } + + struct fragmentMain_Input { + @location(0) @interpolate(flat) count: u32, + @interpolate(flat) @location(4) coordinates: vec2i, + @location(1) @interpolate(flat, either) tagged: u32, + } + + @fragment fn fragmentMain(_arg_0: fragmentMain_Input) -> @location(0) vec4f { + return vec4f(f32(_arg_0.count), f32(_arg_0.coordinates.x), f32(_arg_0.tagged), 1f); + }" + `); }); it('automatically uses flat interpolation for inferred integer varyings', ({ root }) => { @@ -210,6 +230,25 @@ describe('render pipeline behavior', () => { expect(resolved.match(/@location\(0\) @interpolate\(flat\) count: u32/g)).toHaveLength(2); expect(resolved).toContain('@builtin(primitive_index) primitiveIndex: u32'); expect(resolved).not.toContain('@interpolate(flat) @builtin(primitive_index)'); + expect(resolved).toMatchInlineSnapshot(` + "struct VertexOut { + @builtin(position) position: vec4f, + @location(0) @interpolate(flat) count: u32, + } + + @vertex fn vertex() -> VertexOut { + return VertexOut(vec4f(), 1u); + } + + struct FragmentIn { + @location(0) @interpolate(flat) count: u32, + @builtin(primitive_index) primitiveIndex: u32, + } + + @fragment fn fragment(_arg_0: FragmentIn) -> @location(0) vec4f { + return vec4f(f32((_arg_0.count + _arg_0.primitiveIndex))); + }" + `); }); it('does not flat interpolate integer vertex inputs or fragment outputs', ({ root }) => { @@ -236,6 +275,55 @@ describe('render pipeline behavior', () => { expect(resolved).not.toContain( '@fragment fn fragmentMain() -> @location(0) @interpolate(flat) vec4u', ); + expect(resolved).toMatchInlineSnapshot(` + "struct vertexMain_Output { + @builtin(position) position: vec4f, + } + + @vertex fn vertexMain(@location(0) index: u32) -> vertexMain_Output { + return vertexMain_Output(vec4f(f32(index), 0f, 0f, 1f)); + } + + @fragment fn fragmentMain() -> @location(0) vec4u { + return vec4u(1); + }" + `); + }); + + it('automatically uses flat interpolation when resolving a bare vertexFn', () => { + const vertexMain = tgpu.vertexFn({ + out: { count: d.u32, position: d.builtin.position }, + })`{ return Out(); }`; + + const resolved = tgpu.resolve([vertexMain]); + + expect(resolved).toContain('@interpolate(flat) count'); + expect(resolved).toMatchInlineSnapshot(` + "struct vertexMain_Output { + @location(0) @interpolate(flat) count: u32, + @builtin(position) position: vec4f, + } + + @vertex fn vertexMain() -> vertexMain_Output { return vertexMain_Output(); }" + `); + }); + + it('automatically uses flat interpolation when resolving a bare fragmentFn', () => { + const fragmentMain = tgpu.fragmentFn({ + in: { count: d.u32 }, + out: d.vec4f, + })`{ return Out(in.count); }`; + + const resolved = tgpu.resolve([fragmentMain]); + + expect(resolved).toContain('@interpolate(flat) count'); + expect(resolved).toMatchInlineSnapshot(` + "struct fragmentMain_Input { + @location(0) @interpolate(flat) count: u32, + } + + @fragment fn fragmentMain(in: fragmentMain_Input) -> @location(0) vec4f { return vec4f(in.count); }" + `); }); it('resolves with correct locations when pairing up a vertex and a fragment function', ({ From 5772a74717c13b35338026d1da7a15f7bc3cad63 Mon Sep 17 00:00:00 2001 From: Iwo Plaza Date: Fri, 2 Oct 2026 16:20:16 +0200 Subject: [PATCH 4/6] docs: document automatic flat interpolation of integer varyings Add a note to the entry point I/O docs and rely on the implicit behavior in the 3d-fish example. --- .../typegpu-docs/src/content/docs/apis/functions/index.mdx | 7 +++++++ .../typegpu-docs/src/examples/rendering/3d-fish/schemas.ts | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/typegpu-docs/src/content/docs/apis/functions/index.mdx b/apps/typegpu-docs/src/content/docs/apis/functions/index.mdx index febc6d2524..0d6cf8fec9 100644 --- a/apps/typegpu-docs/src/content/docs/apis/functions/index.mdx +++ b/apps/typegpu-docs/src/content/docs/apis/functions/index.mdx @@ -483,6 +483,13 @@ Another thing to note is that there is no need to specify locations of the argum as TypeGPU tries to assign locations automatically. If you wish to, you can assign the locations manually with the `d.location` decorator. +:::note +WGSL requires integer inter-stage values to use flat interpolation. Because of that, TypeGPU automatically adds +`@interpolate(flat)` to integer scalars and vectors (`i32`, `u32`, `vec*i`, `vec*u`) in vertex outputs and fragment inputs. +To choose a different sampling mode (e.g. `'flat, either'`), wrap the value in the `d.interpolate` decorator yourself. +Builtins, vertex inputs and fragment outputs are left untouched. +::: + During WGSL generation, TypeGPU automatically generates structs corresponding to the passed `IORecord`s. In WGSL-implemented functions, input and output structs of the given function can be referenced as `In` and `Out` respectively. Headers in WGSL implementations must be omitted, all input values are accessible through the struct named `in`. diff --git a/apps/typegpu-docs/src/examples/rendering/3d-fish/schemas.ts b/apps/typegpu-docs/src/examples/rendering/3d-fish/schemas.ts index 8a43f179a3..7eab5e1a32 100644 --- a/apps/typegpu-docs/src/examples/rendering/3d-fish/schemas.ts +++ b/apps/typegpu-docs/src/examples/rendering/3d-fish/schemas.ts @@ -45,8 +45,8 @@ export const ModelVertexOutput = { canvasPosition: d.builtin.position, variant: d.f32, textureUV: d.vec2f, - applySeaFog: d.interpolate('flat', d.u32), // bool - applySeaDesaturation: d.interpolate('flat', d.u32), // bool + applySeaFog: d.u32, // bool, flat interpolated automatically + applySeaDesaturation: d.u32, // bool, flat interpolated automatically } as const; export const FishBehaviorParams = d.struct({ From 526f6b07101d3a76ff0923895d213b961592e86c Mon Sep 17 00:00:00 2001 From: Iwo Plaza Date: Fri, 2 Oct 2026 16:26:30 +0200 Subject: [PATCH 5/6] impr: require explicit interpolation for integer varyings in shells Only shellless entry functions infer flat interpolation for integer varyings. Vertex shell outputs and fragment shell inputs with integers lacking an @interpolate attribute now throw during resolution, since they would be a WGSL error anyway. --- .../src/content/docs/apis/functions/index.mdx | 17 +++- .../src/examples/rendering/3d-fish/schemas.ts | 4 +- .../typegpu-gl/tests/glslGenerator.test.ts | 12 +-- .../typegpu/src/core/function/ioSchema.ts | 49 ++++++---- .../src/core/function/tgpuFragmentFn.ts | 14 +-- .../typegpu/src/core/function/tgpuVertexFn.ts | 16 ++-- packages/typegpu/tests/renderPipeline.test.ts | 96 +++++++++++-------- 7 files changed, 127 insertions(+), 81 deletions(-) diff --git a/apps/typegpu-docs/src/content/docs/apis/functions/index.mdx b/apps/typegpu-docs/src/content/docs/apis/functions/index.mdx index 0d6cf8fec9..fdadb8fce3 100644 --- a/apps/typegpu-docs/src/content/docs/apis/functions/index.mdx +++ b/apps/typegpu-docs/src/content/docs/apis/functions/index.mdx @@ -484,10 +484,19 @@ as TypeGPU tries to assign locations automatically. If you wish to, you can assign the locations manually with the `d.location` decorator. :::note -WGSL requires integer inter-stage values to use flat interpolation. Because of that, TypeGPU automatically adds -`@interpolate(flat)` to integer scalars and vectors (`i32`, `u32`, `vec*i`, `vec*u`) in vertex outputs and fragment inputs. -To choose a different sampling mode (e.g. `'flat, either'`), wrap the value in the `d.interpolate` decorator yourself. -Builtins, vertex inputs and fragment outputs are left untouched. +WGSL requires integer inter-stage values (`i32`, `u32`, `vec*i`, `vec*u`) to use flat interpolation. +When an entry function is defined with a shell, its vertex outputs and fragment inputs have to specify it explicitly, +otherwise resolution fails with an error. + +```ts +const vertexOutput = { + position: d.builtin.position, + instanceIndex: d.interpolate('flat', d.u32), +}; +``` + +Entry functions passed directly to `root.createRenderPipeline` without a shell infer their varyings, +so TypeGPU adds `@interpolate(flat)` to integer varyings automatically. ::: During WGSL generation, TypeGPU automatically generates structs corresponding to the passed `IORecord`s. diff --git a/apps/typegpu-docs/src/examples/rendering/3d-fish/schemas.ts b/apps/typegpu-docs/src/examples/rendering/3d-fish/schemas.ts index 7eab5e1a32..8a43f179a3 100644 --- a/apps/typegpu-docs/src/examples/rendering/3d-fish/schemas.ts +++ b/apps/typegpu-docs/src/examples/rendering/3d-fish/schemas.ts @@ -45,8 +45,8 @@ export const ModelVertexOutput = { canvasPosition: d.builtin.position, variant: d.f32, textureUV: d.vec2f, - applySeaFog: d.u32, // bool, flat interpolated automatically - applySeaDesaturation: d.u32, // bool, flat interpolated automatically + applySeaFog: d.interpolate('flat', d.u32), // bool + applySeaDesaturation: d.interpolate('flat', d.u32), // bool } as const; export const FishBehaviorParams = d.struct({ diff --git a/packages/typegpu-gl/tests/glslGenerator.test.ts b/packages/typegpu-gl/tests/glslGenerator.test.ts index 89cd6f54a4..32c2c330c9 100644 --- a/packages/typegpu-gl/tests/glslGenerator.test.ts +++ b/packages/typegpu-gl/tests/glslGenerator.test.ts @@ -782,8 +782,8 @@ describe('GlslGenerator - entry point generation with JS functions', () => { const vertFn = tgpu.vertexFn({ out: { position: d.builtin.position, - x: d.u32, - y: d.u32, + x: d.interpolate('flat', d.u32), + y: d.interpolate('flat', d.u32), }, })(() => { 'use gpu'; @@ -822,8 +822,8 @@ describe('GlslGenerator - entry point generation with JS functions', () => { const vertFn = tgpu.vertexFn({ out: { position: d.builtin.position, - x: d.u32, - y: d.u32, + x: d.interpolate('flat', d.u32), + y: d.interpolate('flat', d.u32), }, })(() => { 'use gpu'; @@ -869,8 +869,8 @@ describe('GlslGenerator - entry point generation with JS functions', () => { const vertFn = tgpu.vertexFn({ out: { position: d.builtin.position, - x: d.u32, - y: d.u32, + x: d.interpolate('flat', d.u32), + y: d.interpolate('flat', d.u32), }, })(() => { 'use gpu'; diff --git a/packages/typegpu/src/core/function/ioSchema.ts b/packages/typegpu/src/core/function/ioSchema.ts index 152e165aa4..f670207295 100644 --- a/packages/typegpu/src/core/function/ioSchema.ts +++ b/packages/typegpu/src/core/function/ioSchema.ts @@ -61,9 +61,7 @@ export function withLocations( return [ key, - autoInterpolateIntegers && !isBuiltin(member) - ? withFlatInterpolationForInteger(member) - : member, + autoInterpolateIntegers ? withFlatInterpolationForInteger(member) : member, ] as const; }) .map(([key, member]) => { @@ -93,7 +91,6 @@ export function withLocations( export function separateBuiltins( schema: Record, locations: Record = {}, - autoInterpolateIntegers = false, ): SeparatedEntryArgs { const positionalArgs: SeparatedEntryArgs['positionalArgs'] = []; const dataFields: Record = {}; @@ -108,10 +105,7 @@ export function separateBuiltins( const dataSchema = Object.keys(dataFields).length > 0 - ? INTERNAL_createStruct( - withLocations(dataFields, locations, autoInterpolateIntegers), - /* isAbstruct */ false, - ) + ? INTERNAL_createStruct(withLocations(dataFields, locations), /* isAbstruct */ false) : undefined; return { dataSchema, positionalArgs }; @@ -133,9 +127,8 @@ export function createIoSchema>( return layout as unknown as IOLayoutToSchema; } - const data = autoInterpolateIntegers ? withFlatInterpolationForInteger(layout) : layout; return ( - getCustomLocation(data) !== undefined ? data : location(0, data) + getCustomLocation(layout) !== undefined ? layout : location(0, layout) ) as IOLayoutToSchema; } @@ -145,13 +138,37 @@ export function createIoSchema>( ) as IOLayoutToSchema; } -function withFlatInterpolationForInteger(data: BaseData): BaseData { - if (isDecorated(data) && data.attribs.some(isInterpolateAttrib)) { - return data; +function needsFlatInterpolation(data: BaseData): boolean { + if (isBuiltin(data) || (isDecorated(data) && data.attribs.some(isInterpolateAttrib))) { + return false; } const inner = isDecorated(data) ? data.inner : data; - return isInteger(inner) || isIntegerVec(inner) - ? interpolate('flat', data as FlatInterpolatableData) - : data; + return isInteger(inner) || isIntegerVec(inner); +} + +function withFlatInterpolationForInteger(data: BaseData): BaseData { + return needsFlatInterpolation(data) ? interpolate('flat', data as FlatInterpolatableData) : data; +} + +/** + * WGSL requires integer inter-stage values to be flat interpolated. Interpolation is only + * inferred for shellless entry functions, so shells have to specify it explicitly. + */ +export function assertIntegerVaryingsInterpolated( + layout: BaseData | Record | undefined, + location: string, +) { + if (layout === undefined || isData(layout)) { + // A single value is either a builtin or a fragment output, neither are varyings + return; + } + + for (const [key, member] of Object.entries(layout)) { + if (needsFlatInterpolation(member)) { + throw new Error( + `Integer value "${key}" in ${location} requires flat interpolation. Wrap its schema in d.interpolate('flat', ...) or d.interpolate('flat, either', ...).`, + ); + } + } } diff --git a/packages/typegpu/src/core/function/tgpuFragmentFn.ts b/packages/typegpu/src/core/function/tgpuFragmentFn.ts index 851000e644..6d68f4a8c3 100644 --- a/packages/typegpu/src/core/function/tgpuFragmentFn.ts +++ b/packages/typegpu/src/core/function/tgpuFragmentFn.ts @@ -25,7 +25,12 @@ import { shaderStageSlot } from '../slot/internalSlots.ts'; import type { AnyAutoCustoms, AutoFragmentIn, AutoFragmentOut } from './autoIO.ts'; import { createFnCore, type FnCore } from './fnCore.ts'; import type { BaseIOData, Implementation, InferIO, IOLayout, IORecord } from './fnTypes.ts'; -import { createIoSchema, type IOLayoutToSchema, separateBuiltins } from './ioSchema.ts'; +import { + assertIntegerVaryingsInterpolated, + createIoSchema, + type IOLayoutToSchema, + separateBuiltins, +} from './ioSchema.ts'; import { stripTemplate } from './templateUtils.ts'; // ---------- @@ -209,11 +214,8 @@ function createFragmentFn( }, [$resolve](ctx: ResolutionCtx): ResolvedSnippet { - const entryInput = separateBuiltins( - shell.in ?? {}, - ctx.varyingLocations ?? {}, - /* autoInterpolateIntegers */ true, - ); + assertIntegerVaryingsInterpolated(shell.in, `fragmentFn (${getName(this)}) input`); + const entryInput = separateBuiltins(shell.in ?? {}, ctx.varyingLocations ?? {}); if (entryInput.dataSchema && isNamable(entryInput.dataSchema)) { entryInput.dataSchema.$name(`${getName(this) ?? ''}_Input`); diff --git a/packages/typegpu/src/core/function/tgpuVertexFn.ts b/packages/typegpu/src/core/function/tgpuVertexFn.ts index da619b4df1..baf60c2d70 100644 --- a/packages/typegpu/src/core/function/tgpuVertexFn.ts +++ b/packages/typegpu/src/core/function/tgpuVertexFn.ts @@ -16,7 +16,12 @@ import type { IORecord, SeparatedEntryArgs, } from './fnTypes.ts'; -import { createIoSchema, type IOLayoutToSchema, separateAllAsPositional } from './ioSchema.ts'; +import { + assertIntegerVaryingsInterpolated, + createIoSchema, + type IOLayoutToSchema, + separateAllAsPositional, +} from './ioSchema.ts'; import { stripTemplate } from './templateUtils.ts'; // ---------- @@ -177,11 +182,10 @@ function createVertexFn( }, [$resolve](ctx: ResolutionCtx): ResolvedSnippet { - const outputWithLocation = createIoSchema( - shell.out, - ctx.varyingLocations, - /* autoInterpolateIntegers */ true, - ).$name(`${getName(this) ?? ''}_Output`); + assertIntegerVaryingsInterpolated(shell.out, `vertexFn (${getName(this)}) output`); + const outputWithLocation = createIoSchema(shell.out, ctx.varyingLocations).$name( + `${getName(this) ?? ''}_Output`, + ); if (typeof implementation === 'string') { core.setExternals('out', { Out: outputWithLocation }); diff --git a/packages/typegpu/tests/renderPipeline.test.ts b/packages/typegpu/tests/renderPipeline.test.ts index 598628f248..660361a185 100644 --- a/packages/typegpu/tests/renderPipeline.test.ts +++ b/packages/typegpu/tests/renderPipeline.test.ts @@ -153,11 +153,11 @@ describe('render pipeline behavior', () => { }); describe('resolve', () => { - it('automatically uses flat interpolation for integer varyings', ({ root }) => { + it('keeps explicit interpolation of integer varyings in shells', ({ root }) => { const vertexMain = tgpu.vertexFn({ out: { - count: d.u32, - coordinates: d.location(4, d.vec2i), + count: d.interpolate('flat', d.u32), + coordinates: d.location(4, d.interpolate('flat', d.vec2i)), tagged: d.interpolate('flat, either', d.u32), position: d.builtin.position, }, @@ -165,8 +165,8 @@ describe('render pipeline behavior', () => { const fragmentMain = tgpu.fragmentFn({ in: { - count: d.u32, - coordinates: d.location(4, d.vec2i), + count: d.interpolate('flat', d.u32), + coordinates: d.location(4, d.interpolate('flat', d.vec2i)), tagged: d.interpolate('flat, either', d.u32), }, out: d.vec4f, @@ -181,19 +181,10 @@ describe('render pipeline behavior', () => { targets: { format: 'r8unorm' }, }); - const resolved = tgpu.resolve([pipeline]); - - expect(resolved.match(/@location\(0\) @interpolate\(flat\) count: u32/g)).toHaveLength(2); - expect( - resolved.match(/@interpolate\(flat\) @location\(4\) coordinates: vec2i/g), - ).toHaveLength(2); - expect( - resolved.match(/@location\(\d+\) @interpolate\(flat, either\) tagged: u32/g), - ).toHaveLength(2); - expect(resolved).toMatchInlineSnapshot(` + expect(tgpu.resolve([pipeline])).toMatchInlineSnapshot(` "struct vertexMain_Output { @location(0) @interpolate(flat) count: u32, - @interpolate(flat) @location(4) coordinates: vec2i, + @location(4) @interpolate(flat) coordinates: vec2i, @location(1) @interpolate(flat, either) tagged: u32, @builtin(position) position: vec4f, } @@ -202,7 +193,7 @@ describe('render pipeline behavior', () => { struct fragmentMain_Input { @location(0) @interpolate(flat) count: u32, - @interpolate(flat) @location(4) coordinates: vec2i, + @location(4) @interpolate(flat) coordinates: vec2i, @location(1) @interpolate(flat, either) tagged: u32, } @@ -290,39 +281,62 @@ describe('render pipeline behavior', () => { `); }); - it('automatically uses flat interpolation when resolving a bare vertexFn', () => { + it('throws when a vertex shell output has an integer without interpolation', () => { const vertexMain = tgpu.vertexFn({ out: { count: d.u32, position: d.builtin.position }, })`{ return Out(); }`; - const resolved = tgpu.resolve([vertexMain]); - - expect(resolved).toContain('@interpolate(flat) count'); - expect(resolved).toMatchInlineSnapshot(` - "struct vertexMain_Output { - @location(0) @interpolate(flat) count: u32, - @builtin(position) position: vec4f, - } - - @vertex fn vertexMain() -> vertexMain_Output { return vertexMain_Output(); }" + expect(() => tgpu.resolve([vertexMain])).toThrowErrorMatchingInlineSnapshot(` + [Error: Resolution of the following tree failed: + - + - vertexFn:vertexMain: Integer value "count" in vertexFn (vertexMain) output requires flat interpolation. Wrap its schema in d.interpolate('flat', ...) or d.interpolate('flat, either', ...).] `); }); - it('automatically uses flat interpolation when resolving a bare fragmentFn', () => { + it('throws when a fragment shell input has an integer without interpolation', () => { const fragmentMain = tgpu.fragmentFn({ - in: { count: d.u32 }, + in: { coordinates: d.location(4, d.vec2i) }, out: d.vec4f, - })`{ return Out(in.count); }`; + })`{ return Out(f32(in.coordinates.x)); }`; - const resolved = tgpu.resolve([fragmentMain]); + expect(() => tgpu.resolve([fragmentMain])).toThrowErrorMatchingInlineSnapshot(` + [Error: Resolution of the following tree failed: + - + - fragmentFn:fragmentMain: Integer value "coordinates" in fragmentFn (fragmentMain) input requires flat interpolation. Wrap its schema in d.interpolate('flat', ...) or d.interpolate('flat, either', ...).] + `); + }); - expect(resolved).toContain('@interpolate(flat) count'); - expect(resolved).toMatchInlineSnapshot(` - "struct fragmentMain_Input { - @location(0) @interpolate(flat) count: u32, + it('keeps explicit interpolation when a vertex shell is paired with a shellless fragment', ({ + root, + }) => { + const vertexMain = tgpu.vertexFn({ + out: { position: d.builtin.position, count: d.interpolate('flat, either', d.u32) }, + })`{ return Out(); }`; + + const pipeline = root.createRenderPipeline({ + vertex: vertexMain, + fragment: ({ count }) => { + 'use gpu'; + return d.vec4f(d.f32(count)); + }, + targets: { format: 'r8unorm' }, + }); + + expect(tgpu.resolve([pipeline])).toMatchInlineSnapshot(` + "struct vertexMain_Output { + @builtin(position) position: vec4f, + @location(0) @interpolate(flat, either) count: u32, } - @fragment fn fragmentMain(in: fragmentMain_Input) -> @location(0) vec4f { return vec4f(in.count); }" + @vertex fn vertexMain() -> vertexMain_Output { return vertexMain_Output(); } + + struct FragmentIn { + @location(0) @interpolate(flat, either) count: u32, + } + + @fragment fn fragment(_arg_0: FragmentIn) -> @location(0) vec4f { + return vec4f(f32(_arg_0.count)); + }" `); }); @@ -335,7 +349,7 @@ describe('render pipeline behavior', () => { bar: d.vec3f, baz: d.location(0, d.vec3f), baz2: d.location(5, d.f32), - baz3: d.u32, + baz3: d.interpolate('flat', d.u32), pos: d.builtin.position, }, })(() => ({ @@ -349,7 +363,7 @@ describe('render pipeline behavior', () => { const fragmentMain = tgpu.fragmentFn({ in: { - baz3: d.u32, + baz3: d.interpolate('flat', d.u32), bar: d.vec3f, foo: d.location(2, d.vec3f), baz2: d.f32, @@ -393,14 +407,14 @@ describe('render pipeline behavior', () => { position: d.builtin.position, baz: d.location(0, d.vec3f), baz2: d.location(5, d.f32), - baz3: d.u32, + baz3: d.interpolate('flat', d.u32), }, })`{ return Out(); }`; const fragmentMain = tgpu.fragmentFn({ in: { position: d.builtin.position, - baz3: d.u32, + baz3: d.interpolate('flat', d.u32), bar: d.vec3f, foo: d.location(2, d.vec3f), baz2: d.f32, From 5a975d7f74a6b4563baf20329f76f08f84d85ba5 Mon Sep 17 00:00:00 2001 From: Iwo Plaza Date: Fri, 2 Oct 2026 16:53:42 +0200 Subject: [PATCH 6/6] fix: fall back to '' in integer interpolation errors --- packages/typegpu/src/core/function/tgpuFragmentFn.ts | 5 ++++- packages/typegpu/src/core/function/tgpuVertexFn.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/typegpu/src/core/function/tgpuFragmentFn.ts b/packages/typegpu/src/core/function/tgpuFragmentFn.ts index 6d68f4a8c3..19b3b5822e 100644 --- a/packages/typegpu/src/core/function/tgpuFragmentFn.ts +++ b/packages/typegpu/src/core/function/tgpuFragmentFn.ts @@ -214,7 +214,10 @@ function createFragmentFn( }, [$resolve](ctx: ResolutionCtx): ResolvedSnippet { - assertIntegerVaryingsInterpolated(shell.in, `fragmentFn (${getName(this)}) input`); + assertIntegerVaryingsInterpolated( + shell.in, + `fragmentFn (${getName(this) ?? ''}) input`, + ); const entryInput = separateBuiltins(shell.in ?? {}, ctx.varyingLocations ?? {}); if (entryInput.dataSchema && isNamable(entryInput.dataSchema)) { diff --git a/packages/typegpu/src/core/function/tgpuVertexFn.ts b/packages/typegpu/src/core/function/tgpuVertexFn.ts index baf60c2d70..e0d2d4c388 100644 --- a/packages/typegpu/src/core/function/tgpuVertexFn.ts +++ b/packages/typegpu/src/core/function/tgpuVertexFn.ts @@ -182,7 +182,10 @@ function createVertexFn( }, [$resolve](ctx: ResolutionCtx): ResolvedSnippet { - assertIntegerVaryingsInterpolated(shell.out, `vertexFn (${getName(this)}) output`); + assertIntegerVaryingsInterpolated( + shell.out, + `vertexFn (${getName(this) ?? ''}) output`, + ); const outputWithLocation = createIoSchema(shell.out, ctx.varyingLocations).$name( `${getName(this) ?? ''}_Output`, );