Repository: software-mansion/TypeGPU
Pull request #1421 (2025-06-27 15:28:17)

feat: Apply copy semantics to function calls in JS
This PR introduces a convention where schema calls can create deep copies of corresponding objects. For example,
```typescript
const Schema = d.struct({ prop: d.vec2f });
const struct = Schema({ prop: d.vec2f(1, 2) });
const clone = Schema(struct);
expect(struct).toStrictEqual(clone);
expect(struct).not.toBe(clone);
expect(struct.prop).not.toBe(clone.prop);
```
Changes:
- deep copy structs in struct schema calls,
- deep copy tgpuFn arguments on calls in JS,
- updated docs and tests.

Arrays do not follow this convention yet, I left it as a part of #1254

github-actions[bot]: **pkg.pr.new**

*packages*
```
pnpm i https://pkg.pr.new/software-mansion/TypeGPU/typegpu@1421
pnpm i https://pkg.pr.new/software-mansion/TypeGPU/typegpu@398456cbf8121a05c234560b46a09b2480119c0a
```

*benchmark*
[view benchmark](https://docs.swmansion.com/TypeGPU/benchmark?p=npm-latest_pr-1421)

*commit*
[view commit](https://github.com/software-mansion/TypeGPU/commit/398456cbf8121a05c234560b46a09b2480119c0a)

Patch:
diff --git a/apps/typegpu-docs/src/content/docs/fundamentals/tgsl.mdx b/apps/typegpu-docs/src/content/docs/fundamentals/tgsl.mdx
index 76d9b262..b439688c 100644
--- a/apps/typegpu-docs/src/content/docs/fundamentals/tgsl.mdx
+++ b/apps/typegpu-docs/src/content/docs/fundamentals/tgsl.mdx
@@ -114,10 +114,12 @@ This means that, for example, `console.log()` calls will not work on the GPU.
 * **Differences between JS on the CPU and GPU** --
 TGSL is developed to work on the GPU the same as on the CPU as much as possible, 
 however because of the fundamental differences between the JavaScript and WGSL languages, it is not guaranteed to always be the case.
-Currently the biggest known difference is that vectors (and matrices) are treated as reference types in JavaScript and value types in WGSL.
-That is two vectors in JavaScript are never equal to each other, even when they store the same values. 
-Also when passing them to functions, they are able to be modified there, unless the user specifically clones the passed argument
-(in WGSL vectors are copied by default). When using TGSL on the GPU, the behavior is that of WGSL, not JS, as one would expect.
+
+    Currently the biggest known difference is that vectors, matrices and structs are treated as reference types in JavaScript and value types in WGSL.
+That is, on the WGSL side, the assignment operator copies the value instead of the reference, and two different vectors can be equal to each other if only they store the same values, unlike in JS, where they need to point to the same reference. 
+To somehow alleviate this issue, when passing arguments to tgpu functions on JS side, we perform a deep copy of them (note that in WGSL arguments are immutable by default). 
+
+    When using TGSL on the GPU, the behavior is that of WGSL, not JS, as one would expect.
 Therefore some WGSL knowledge is still required, even when opting out for TGSL.
 
 * **.value** --
diff --git a/apps/typegpu-docs/src/content/docs/reference/data-schema-cheatsheet.mdx b/apps/typegpu-docs/src/content/docs/reference/data-schema-cheatsheet.mdx
index ab3907af..59ed5a83 100644
--- a/apps/typegpu-docs/src/content/docs/reference/data-schema-cheatsheet.mdx
+++ b/apps/typegpu-docs/src/content/docs/reference/data-schema-cheatsheet.mdx
@@ -39,10 +39,33 @@ import { Code } from '@astrojs/starlight/components';
 
 Apart from the listed constructors, all vectors can be created from any mix of other vectors and numbers, like this:
 
-<Code code="
+```ts
 import { vec2f, vec3f, vec4f } from 'typegpu/data';
 
 const a = vec2f(1, 2);  // 1, 2
 const b = vec3f(0, a);  // 0, 1, 2
 const c = vec4f(b.xz, a.xx);  // 0, 2, 1, 1 
-" lang="js" />
+```
+
+## Constructor conventions
+
+Scalar, vector and matrix schemas can be called with no arguments to get a default value of the type represented by the schema.
+
+```ts
+import { f32, bool, vec3f } from 'typegpu/data';
+
+const f32Default = f32(); // 0.0
+const boolDefault = bool(); // false
+const vecDefault = vec3f(); // vec3f(0.0, 0.0, 0,0)
+```
+
+Scalar, vector, matrix and struct schemas can be called with an instance of the type represented by the schema to create a deep copy of the argument.
+
+```ts
+import { vec2f, struct } from 'typegpu/data';
+
+const StructSchema = struct({ prop: vec2f });
+const myStruct = StructSchema({ prop: vec2f(1, 2) });
+const myClone = StructSchema(myStruct); // a deep copy of myStruct
+```
+
diff --git a/package.json b/package.json
index 97cd5f54..d2eb646e 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
   "scripts": {
     "dev": "DEV=true pnpm --parallel -r dev",
     "fix": "biome lint --write . && deno fmt",
-    "test": "pnpm run test:circular-deps && pnpm run test:types && pnpm run test:style && pnpm run test:unit-and-attest",
+    "test": "pnpm run test:types && pnpm run test:style && pnpm run test:unit-and-attest && pnpm run test:circular-deps",
     "test:circular-deps": "pnpm dpdm -T --exit-code circular:1 packages/**/index.ts",
     "test:types": "pnpm run -r --parallel test:types",
     "test:style": "biome lint . && deno fmt --check",
diff --git a/packages/typegpu/src/core/function/tgpuFn.ts b/packages/typegpu/src/core/function/tgpuFn.ts
index 73bf81b0..befb0a16 100644
--- a/packages/typegpu/src/core/function/tgpuFn.ts
+++ b/packages/typegpu/src/core/function/tgpuFn.ts
@@ -1,4 +1,5 @@
 import { type AnyData, snip, UnknownData } from '../../data/dataTypes.ts';
+import { schemaCallWrapper } from '../../data/utils.ts';
 import { Void } from '../../data/wgslTypes.ts';
 import { createDualImpl } from '../../shared/generators.ts';
 import type { TgpuNamable } from '../../shared/meta.ts';
@@ -231,7 +232,11 @@ function createFn<ImplSchema extends AnyFn>(
         );
       }
 
-      return implementation(...args);
+      const castAndCopiedArgs = args.map((arg, index) =>
+        schemaCallWrapper(shell.argTypes[index], arg)
+      ) as InferArgs<Parameters<ImplSchema>>;
+
+      return implementation(...castAndCopiedArgs);
     },
     (...args) =>
       snip(
diff --git a/packages/typegpu/src/data/struct.ts b/packages/typegpu/src/data/struct.ts
index f8a6250f..00d11ab7 100644
--- a/packages/typegpu/src/data/struct.ts
+++ b/packages/typegpu/src/data/struct.ts
@@ -1,5 +1,6 @@
 import { getName, setName } from '../shared/meta.ts';
 import { $internal } from '../shared/symbols.ts';
+import { schemaCallWrapper } from './utils.ts';
 import type { AnyWgslData, WgslStruct } from './wgslTypes.ts';
 
 // ----------
@@ -20,11 +21,18 @@ import type { AnyWgslData, WgslStruct } from './wgslTypes.ts';
 export function struct<TProps extends Record<string, AnyWgslData>>(
   props: TProps,
 ): WgslStruct<TProps> {
-  const struct = <T>(props: T) => props;
-  Object.setPrototypeOf(struct, WgslStructImpl);
-  struct.propTypes = props;
+  // in the schema call, create and return a deep copy
+  // by wrapping all the values in corresponding schema calls
+  const structSchema = <T extends TProps>(instanceProps: T) =>
+    Object.fromEntries(
+      Object.entries(props).map((
+        [key, schema],
+      ) => [key, schemaCallWrapper(schema, instanceProps[key])]),
+    );
+  Object.setPrototypeOf(structSchema, WgslStructImpl);
+  structSchema.propTypes = props;
 
-  return struct as WgslStruct<TProps>;
+  return structSchema as WgslStruct<TProps>;
 }
 
 // --------------
diff --git a/packages/typegpu/src/data/utils.ts b/packages/typegpu/src/data/utils.ts
new file mode 100644
index 00000000..aaae0f43
--- /dev/null
+++ b/packages/typegpu/src/data/utils.ts
@@ -0,0 +1,16 @@
+/**
+ * A wrapper for `schema(item)` call.
+ * Logs a warning if the schema is not callable.
+ */
+export function schemaCallWrapper<T>(schema: unknown, item: T): T {
+  let result = item;
+  try {
+    result = (
+      schema as unknown as ((item: typeof result) => typeof result)
+    )(item);
+  } catch {
+    const maybeType = (schema as { type: string })?.type;
+    console.warn(`Schema of type ${maybeType ?? '<unknown>'} is not callable.`);
+  }
+  return result;
+}


Test patch:
diff --git a/packages/typegpu/tests/struct.test.ts b/packages/typegpu/tests/struct.test.ts
index 6062623c..7f473802 100644
--- a/packages/typegpu/tests/struct.test.ts
+++ b/packages/typegpu/tests/struct.test.ts
@@ -11,6 +11,7 @@ import {
   struct,
   u32,
   type v3u,
+  vec2f,
   vec2h,
   vec2u,
   vec3f,
@@ -255,4 +256,25 @@ describe('struct', () => {
     // @ts-expect-error
     TestStruct({ x: 1, z: 2 });
   });
+
+  it('can be called to create a deep copy of other struct', () => {
+    const schema = struct({ nested: struct({ prop1: vec2f, prop2: u32 }) });
+    const instance = schema({ nested: { prop1: vec2f(1, 2), prop2: 21 } });
+
+    const clone = schema(instance);
+
+    expect(clone).toStrictEqual(instance);
+    expect(clone).not.toBe(instance);
+    expect(clone.nested).not.toBe(instance.nested);
+    expect(clone.nested.prop1).not.toBe(instance.nested.prop1);
+  });
+
+  it('can be called to strip extra properties of a struct', () => {
+    const schema = struct({ prop1: vec2f, prop2: u32 });
+    const instance = { prop1: vec2f(1, 2), prop2: 21, prop3: 'extra' };
+
+    const clone = schema(instance);
+
+    expect(clone).toStrictEqual({ prop1: vec2f(1, 2), prop2: 21 });
+  });
 });
diff --git a/packages/typegpu/tests/tgslFn.test.ts b/packages/typegpu/tests/tgslFn.test.ts
index 7a469b06..98aea361 100644
--- a/packages/typegpu/tests/tgslFn.test.ts
+++ b/packages/typegpu/tests/tgslFn.test.ts
@@ -758,3 +758,124 @@ describe('TGSL tgpu.fn function', () => {
     attest(fun).type.toString.snap('TgpuFn<(args_0: F32, args_1: F32) => F32>');
   });
 });
+
+describe('tgpu.fn arguments', () => {
+  it('casts u32', () => {
+    const fn = tgpu['~unstable'].fn([d.u32], d.f32)((e) => {
+      'kernel & js';
+      return e;
+    });
+
+    const result = fn(3.14);
+
+    expect(result).toBe(3);
+  });
+
+  it('returns a copy of a float vector', () => {
+    const vec = d.vec3f(1, 2, 3);
+    const fn = tgpu['~unstable'].fn([d.vec3f], d.vec3f)((e) => {
+      'kernel & js';
+      return e;
+    });
+
+    const clone = fn(vec);
+
+    expect(clone).toStrictEqual(vec);
+    expect(clone).not.toBe(vec);
+  });
+
+  it('returns a copy of a bool vector', () => {
+    const vec = d.vec4b(false, true, false, true);
+    const fn = tgpu['~unstable'].fn([d.vec4b], d.vec4b)((e) => {
+      'kernel & js';
+      return e;
+    });
+
+    const clone = fn(vec);
+
+    expect(clone).toStrictEqual(vec);
+    expect(clone).not.toBe(vec);
+  });
+
+  it('returns a copy of a matrix', () => {
+    const mat = d.mat2x2f(1, 2, 3, 7);
+    const fn = tgpu['~unstable'].fn([d.mat2x2f], d.mat2x2f)((e) => {
+      'kernel & js';
+      return e;
+    });
+
+    const clone = fn(mat);
+
+    expect(clone).toStrictEqual(mat);
+    expect(clone).not.toBe(mat);
+  });
+
+  it('returns a deep copy of a struct', () => {
+    const struct = { prop: d.vec2f(1, 2) };
+    const fn = tgpu['~unstable'].fn(
+      [d.struct({ prop: d.vec2f })],
+      d.struct({ prop: d.vec2f }),
+    )((e) => {
+      'kernel & js';
+      return e;
+    });
+
+    const clone = fn(struct);
+
+    expect(clone).toStrictEqual(struct);
+    expect(clone).not.toBe(struct);
+    expect(clone.prop).not.toBe(struct.prop);
+  });
+
+  it('returns a deep copy of a nested struct', () => {
+    const schema = d.struct({
+      nested: d.struct({ prop1: d.vec2f, prop2: d.u32 }),
+    });
+    const struct = schema({ nested: { prop1: d.vec2f(1, 2), prop2: 21 } });
+    const fn = tgpu['~unstable'].fn([schema], schema)((e) => {
+      'kernel & js';
+      return e;
+    });
+
+    const clone = fn(struct);
+
+    expect(clone).toStrictEqual(struct);
+    expect(clone).not.toBe(struct);
+    expect(clone.nested).not.toBe(struct.nested);
+  });
+
+  // TODO: make it work
+  // it('returns a deep copy of an array', () => {
+  //   const array = [d.vec2f(), d.vec2f()];
+  //   const fn = tgpu['~unstable'].fn(
+  //     [d.arrayOf(d.vec2f, 2)],
+  //     d.arrayOf(d.vec2f, 2),
+  //   )(
+  //     (e) => {
+  //       'kernel & js';
+  //       return e;
+  //     },
+  //   );
+
+  //   const clone = fn(array);
+
+  //   expect(clone).toStrictEqual(array);
+  //   expect(clone).not.toBe(array);
+  //   expect(clone[0]).not.toBe(array[0]);
+  // });
+
+  it('does not modify its argument', () => {
+    const vec = d.vec3f();
+    const fn = tgpu['~unstable'].fn([d.vec3f])(
+      (e) => {
+        'kernel & js';
+        const copy = e; // in WGSL, this would copy the value, in JS it only copies the reference
+        copy[0] = 1;
+      },
+    );
+
+    fn(vec);
+
+    expect(vec).toStrictEqual(d.vec3f());
+  });
+});