Type System
In order for it to be compiled into WebGPU shaders, the Javascript function passed into ti.kernel()
needs to obey certain rules that are not common to Javascript code. Most importantly, in normal Javascript-scope code, types of variables are not fixed and may change at runtime. This is different from kernel-scope code, where variables are "statically-typed". This means that types are fixed and determined at compile-time. When writing kernel code, it's useful to have a mental model of the type system in place, which this document will describe.
We will begin with four of the most simple and commonly used data types: primitives, vectors, matrices, and structs. We will discuss how to declare variables of these types, and common operations on them. We will also show how to declare these types as arguments to kernels. Beyond these simple types, "variables" in kernels can also refer to data containers (i.e. fields and textures) or lambdas/functions, which will be discussed in separate doc pages.
Primitives
In taichi.js
, there are only two primitive types at the moment: i32
and f32
. The compiler determines the type of a variable depending on the expression of its initializer:
let i = 1 // i32
let f = 1.0 // f32
i32
values can be casted to f32
via ti.f32(..)
, and f32
values can be casted to i32
via ti.i32(..)
:
let i = ti.i32(4.2) // i32
let f = ti.f32(42) // f32
when a valid operation is performed between an i32
and a f32
, the integer is automatically promoted:
let i = ti.i32(4.2) // i32
let f = ti.f32(42) // f32
let f2 = f + i // f32
Due to Javascript's lack of type annotations, kernel argument types in taichi.js
are specified via an extra argument to the ti.kernel()
call. The following example shows how to pass an i32
and a f32
to a kernel, which returns their sum:
let k = ti.kernel(
{ i: ti.i32, f: ti.f32 },
(i, f) => {
return i + f
}
)
console.log(await k(1, 2.5)) // prints 3.5
If you don't specify the arguments, then taichi.js
will assume they are f32
s by default:
let k = ti.kernel(
(f1, f2) => {
return f1 + f2
}
)
console.log(await k(1.5, 2.5)) // prints 4.0
Vectors
Vectors are fixed-size tuples of primitives. In taichi.js
, tuples are declared via the Javascript array notation [...]
. For example:
let vi = [0, 1, 2]
let vf = [0.0, 1.0, 2.0, 3.0]
Individual components of of vectors can be acessed by indexing with an integerv[i]
. Notice that, at the moment, indices used to access vectors must be compile-time constant expressions.
let v = [0.0, 1.0, 2.0, 3.0]
let f = v[0] // 0.0
f = v[1] // 1.0
Alternatively, for accessing the first 4 elements, you also write .x/.y/.z/.w
or .r/.g/.b/.a
. This is handy if your vector represents a 3D point/direction, or a color:
let v = [0.0, 1.0, 2.0, 3.0]
let f = v.x // 0.0
f = v.g // 1.0
taichi.js
also supports "swizzling":
let v = [0, 1, 2, 3]
let v1 = v.xyz // [0, 1, 2]
let v2 = v.bgr // [2, 1, 0]
Vectors of the same type can have arithmetic operations among each other, where arithmetics are applied component wise. For example
let v = [1.0, 2.0] + [3.0, 4.0] // v = [4.0, 6.0]
Vectors can also have arithmetic operations with scalars, where the scalar is broadcasted:
let v = [1.0, 2.0] * 2.0 // v = [2.0, 4.0]
Beyond arithmetics, taichi.js
contains many built-in mathematical operations which you can make use of. These are listed here.
For passing vectors into kernels as arguments, declare the argument as ti.types.vector( <primitive type name>, <component count> )
:
let length = ti.kernel(
{v: ti.types.vector(ti.f32, 2)},
(v) => {
return ti.sqrt(v.x * v.x + v.y * v.y)
}
)
console.log(await length([3, 4])) // prints 5
Matrices
Matrices are fixed size of tuples of vectors of the same size. In taichi.js
, matrices are declared via 2D arrays of primitives. For example:
let identity = [[1.0, 0.0],
[0.0, 1.0]]
Matrices in taichi.js
are row-major. This means that each vector in the initializer is a row-vector, and when accessing matrices using an integer index i
, the i
th row will be returned:
let m = [[1.0, 2.0], [3.0, 4.0]]
let v = m[0] // [1.0, 2.0]
As with vectors, arithmetic operations among matrices are done component-wise, and primitives can be broadcased to operate with matrices:
let m1 = [[1.0, 2.0], [3.0, 4.0]] + [[4.0, 3.0], [2.0, 1.0]] // [[5.0, 5.0], [5.0, 5.0]]
let m2 = [[1.0, 2.0], [3.0, 4.0]] * 2.0 // [[2.0, 4.0], [6.0, 8.0]]
Common operations among matrices and vectors are available via built-in functions. For example, for matrix-vector multiplication, you can write it using the matmul
built-in. There are two syntaxes for this:
let m = [[1.0, 2.0], [3.0, 4.0]]
let v = m[0] // [1.0, 2.0]
let mv1 = m.matmul(v)
let mv2 = ti.matmul(m, v)
// mv1 == mv2 == [5, 11]
For passing vectors into kernels as arguments, declare the argument as ti.types.matrix( <primitive type name>, <row count>, <column count> )
:
let trace = ti.kernel(
{m: ti.types.matrix(ti.f32, 2, 2)},
(m) => {
return m[0][0] + m[1][1];
}
)
console.log(await trace([[1.0, 2.0], [3.0, 4.0]])) // prints 5
Structs
Structs are collections of values with names. These are described with the Javascript object notation:
let particle = {
index: 0,
position: [1.0, 2.0, 3.0],
velocity: [4.0, 5.0, 6.0]
}
As with Javascript objects, elements of structs can be accessed via the dot notation:
let o = {i:0, v: [1.0, 2.0]};
let v = o.v; // [1.0, 2.0];
For passing vectors into kernels as arguments, declare the argument as ti.types.struct({ <element name> : <element type> ... })
:
let particleType = ti.types.struct({
position: ti.types.vector(ti.f32, 3),
velocity: ti.types.vector(ti.f32, 3),
})
let predictNewPosition = ti.kernel(
{p: particleType, time: ti.f32},
(p, time) => {
return p.position + time * p.velocity;
}
)
console.log(await predictNewPosition({
position: [0, 0, 0],
velocity: [1, 1, 1]
}, 2))
// prints [2, 2, 2]