software-mansion / software-mansion/TypeGPU
(RFC) feat: Objects pretending to be primitives (branded numbers on the type level)
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 3.2k
- Forks
- 122
- Avg merge
- 3d 5h
- Merged PRs (30d)
- 34
Description
Better docs
Standard (and user-defined) functions can have their proper accepted and return types defined clearly, available on hover.
Better view into the flow of types
With tsover enabled, d.f32(2) + d.f32(3.5) is no longer typed as number, it's typed as d.f32. Extrapolating this property, we can hover over any variable and know its proper WGSL type.
const main = () => {
'use gpu';
const c = d.f32(2) + d.f32(3.5);
// ^ d.f32
};
With tsover disabled, d.f32 can just be an alias for number
const main = () => {
'use gpu';
const c = d.f32(2) + d.f32(3.5);
// ^ number
};
Referentiality
Even though d.f32 is typed as number & { ... }, it's still an object at runtime. This allows us to hold more interesting metadata when executing a function in JS mode, but it also introduces a bit more complications.
One of them is that values returned by d.f32 are not primitives, therefore assigning the same value to multiple variables doesn't copy.
But perhaps this is not a problem if we keep these objects immutable. So what if two variables hold the same object, we know that the inner value will never change.
const main = () => {
'use gpu';
const a = d.f32(0);
const b = a; // holds the same reference
};
typeof foo === 'number'
The primitives will be objects at runtime, meaning typeof foo will be 'object' and not 'number' as might be expected.
To fix this, we have to limit where these numeric objects can end up, while still making good use of them in shader functions.
My current idea for this is to transform typeof foo calls into __tsover_typeof__ and allow that operator to be overridable by tsover. I wouldn't want to patch up this behavior outside of 'use gpu' functions though, so to limit numeric objects to live only inside shader functions:
- Functions return numeric objects only when called from within another shader function, otherwise they're coerced back to vanilla numbers. This can be tracked by injecting
globalThis.__TYPEGPU_ENTER_FN__(fn); try { /* the function code */ } finally { globalThis.__TYPEGPU_EXIT_FN__(fn); }into each 'use gpu' function body - Numeric objects passed as arguments to comptime functions are reduced to vanilla numbers.
Reflection
Passing around objects instead of primitives has the added benefit of us being able to introspect the value's type and act on it at compile time:
const flip = (hello: d.f32 | d.f16): d.f32 | d.f16 => {
'use gpu';
if (std.typeOf(hello).type === 'f32') {
return d.f16(hello);
}
return d.f32(hello);
};
Limitation
The limitation is that numeric literals aren't assignable to these branded types, and such have to be wrapped each time.
// Without tsover
function remap(a: f32, b: f32, value: f32): f32 {
'use gpu';
return f32((value - a) / (b - a));
}
// With tsover
function remap(a: f32, b: f32, value: f32): f32 {
'use gpu';
return (value - a) / (b - a);
}
function main() {
const value = remap(f32(1), f32(2), f32(1.5));
// instead of: const value = remap(1, 2, 1.5);
}
We could also alias f32 to make it a bit easier on the eyes
function main() {
const value = remap(f(1), f(2), f(1.5));
}
We could assume that number is f32 by default, but then for loops become very confusing
for (let i = 0; i < 10; i++) {
// ...
}
becomes:
for (var i = 0f; i < 10f; i++) {
// ...
}
This goes away however if we make for loops a special case. Worth investigating.
💡 What if we made strict numerals apply only with tsover?
Test out if using d.f32 can be made equivalent to just using number when tsover is disabled.
Then, unfortunately, it's not much of a guarantee when libraries say they expect say d.f32
💡 What if d.f32 were always branded types, but d.InferGPU would return different things depending on tsover's existance?
With tsover:
function remap(a: f32, b: f32, value: f32) {
'use gpu';
return (value - a) / (b - a);
}
const time = root.createUniform(f32);
function main() {
'use gpu';
const t = time.$; // => f32
const factor = std.saturate(remap(f32(1), f32(2), t));
}
Without tsover:
function remap(a: f32, b: f32, value: f32) {
'use gpu';
return (value - a) / (b - a);
}
const time = root.createUniform(f32);
function main() {
'use gpu';
const t = time.$; // => number
const factor = std.saturate(remap(f32(1), f32(2), d.f32(t)));
}
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reviewing the RFC's proposed branded d.f32 types, tsover behavior, runtime referentiality, typeof handling, and numeric-literal limitations. Done would require an agreed design for these trade-offs and a concrete implementation plan identifying the affected compiler and runtime entry points.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- compilers
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Needs clarification
- Newbie friendliness
- 20/100