Builtin Library
- ..
- .metadata311 B
- README.md16.9 KB
- abi_call.luau8.9 KB
- abi_kernel.luau61.8 KB
- abi_transfer.luau15.2 KB
- init.luau1.6 KB
README.md
abi
The ops every procgen node is built from. Each states where it runs in its runs field, and none calls another op.
Ops
| Op | Runs | Does |
|---|---|---|
device.kernel | gpu | Runs a .computeShader over a domain of elements and returns what it wrote as columns |
transfer.upload | cpu_to_gpu | Turns CPU numbers, lists and tables into device columns |
transfer.readback | gpu_to_cpu | Turns device columns and live counts into CPU values, a frame or more later |
cpu.call | cpu | Calls a Luau function with declared return types and a time ceiling |
cpu.read | cpu | A cpu.call over state outside the graph, with a digest of what it reads |
device.bound | gpu | Answers a count as a live count that states the most it can reach |
device.live | gpu | Answers a column carrying a count, so a reader of it takes the live elements only |
device.guard | gpu | Holds a cook to a condition the device answers, checked a cook later when a recorded list replays |
device.kernel
kernel (param) is the shader, a reference resolved from a literal where the node is written
(asset.resolve("<identity>", "computeShader"); a name is refused); specialize (param) gives each of its specialize
parameters one of its options. bind (input) gives every binding its value and domain (input) says how many
elements run. uniforms (input) gives the shader's params by name, each a Number or a Bool, and a name the shader does
not declare is refused; step (input) gives numbers the shader takes where it declares a param of the same name (dt,
time, ...) and leaves the others out; columns (input) is a table of Columns, or a class of a Geometry, whose
columns every read binding left unbound takes by its own name.
- A buffer binding takes a Column. A binding the shader writes and that is bound to a Column writes a copy, and the copy comes back under the binding's name.
- A
columnsbinding takes a table of Columns, packed on the device into one buffer in the order the binding'sorderfield states (order: "shapeKind center extent"; the table must hold exactly those columns), and in sorted-name order where the binding states none. A kernel that reads its columns by slot number statesorder; one that reads them byNAME_col_<column>needs none. specializetakes the tablefieldc.walkSpec(step, opts).specializestates as it stands (the walk kernel'sWALK_STEP,WALK_STEP_HI,WALK_DONEandWALK_PRELUDE), and the resources the spec runs over come fromfieldc.bindSpec(spec): itscols(a table of columns already packed on the device) binds to the kernel'scolumnsbinding and itsu(the numbers the chain carries, a device buffer) to its readbufferbinding. The caller that made them lets them go (bound.release()) once the kernel's outputs are no longer read.- A Field bound to a binding is evaluated by the field compiler (
fieldc.evaluate) in one fused dispatch over the domain, and the column it lands in stands as long as the outputs of the kernel do. bind.columnsis a domain (a record of Columns, or a class of a Geometry): every read binding the bind left unnamed takes the column of the same name, and an unboundcolumnsbinding takes every column of it.- An appended output bound to a column that an append returned writes a copy of it, and the copy keeps the live count that column carries, so a later pass appends after what an earlier one appended.
- A param takes a Number or a Bool (1 or 0). A Vec spreads over
<param>_xto<param>_w.count,timeandframeare names any node may give; a kernel that declares none of them leaves them out. - A Texture binds its
texelscolumn, and sets<name>_widthand<name>_heightwhere the shader declares them. domainis a Number, a Column (its elements), or a live count: a count Column, an appended output, or the record{ count = <one-word Column>, capacity = <Number> }. A live count dispatches over its capacity, and the shader reads the count through abufferbinding nameddomain_count. A shader that declaresdomain_countover a plain number gets the number in it. The live-count form is stated in "Over a live count" below.- An output with
countorcapacityis allocated before the dispatch. An appended output comes back as a Column (its_capacityand_liveCountset) and as<name>_count.
Output: columns, a Record holding every binding the shader wrote as a Column by binding name, and for an appended
output <name>_count, its live count.
Column elements are 32-bit words: an Int column holds i32, a UInt column u32, every other type f32
(column:element()). A kernel's i32 and u32 outputs are Int and UInt columns. The first run of a kernel
waits for its program to be built, so a readback asked for after the run sees what the dispatch wrote.
A column whose words are not the ones a binding reads is cast by value before the dispatch: a Float column meeting an
i32 or u32 binding (and an Int or UInt column meeting an f32 one) is read as the numbers it holds, not as the bits
of its words. The cast covers read bindings and in-place bindings; what a kernel writes back through an in-place binding
is the column of the binding's type (Int for i32).
Over a live count
A kernel runs over a plain number of elements (every slot live) or over a capacity whose live count sits in a one-word device buffer. A node that deletes, welds, expands or pairs elements states the count it made on the device, and the next kernel takes it as its domain, so the count never crosses to the CPU between them.
What a node passes. domain is the count and the capacity it is bounded by: found.indices.count (the count of an
appended output, whose capacity the output carries), an appended output Column itself, or the record
{ count = <one-word Column>, capacity = <Number> } for any other count held on the device (a scan's total, a
count made by proc.device.count; its words are read as u32). A Number is the plain form, as before.
What a kernel declares. In bindings.yaml, a count binding that exists only in the variant that reads a count, and
the specialization that picks the variant:
bindings:
- { name: domain_count, kind: buffer, access: read, element: u32, when: "COUNT=device" }
specialize:
- { name: COUNT, options: [plain, device] }
device.kernel runs the COUNT=device variant when the domain is a live count, and the plain variant (the bindings
and the code the kernel always had) when it is a Number. A kernel that declares domain_count without when reads it
in every variant, and gets the number in it over a plain domain.
A count that is arithmetic over live counts (geometry.point.count * 8, min(kept.count, room)) is a domain too when
the arithmetic only grows with the counts it reads: the dispatch covers the most it can reach and the kernel reads the
count the arithmetic comes to, evaluated on the device. Arithmetic that takes a count away from something is read
back where the dispatch needs the number.
An output sized by a live count. An output whose count states a name that is no param (count: "nv") takes its
size from the bind of that name. Bound to a number, the output holds that many elements. Bound to { live = <count> },
where the count is a count Column that carries its capacity or arithmetic over counts that only grows with them, the
output is allocated for the capacity and carries the count as its live count, with nothing read back.
What the shader does. The dispatch covers the capacity (zero_n()), outputs sized by n hold the capacity, and
the live count is min(domain_count_get(0u), zero_n()). The code that reads it sits in // @when COUNT device ...
// @end blocks (compute_abi removes a block from the variants it does not name). A slot at or past the live count
reads nothing and writes the inert value of each output it owns:
| Kernel | Inert value of a slot past the live count |
|---|---|
gather | dst is zeros |
compact | inverse is -1; indices is -1 past the kept ones |
scan | dst is the total of the last group (0 for an empty run) |
jump (rank, min) | parent_out is the slot itself, value_out is 0 |
loops | stage clear writes every slot of the dispatch; stage link: next is the slot itself, head is 0 |
loops_cut | head is 0, cut is the slot itself |
loops_place | ring is -1, pos is 0 |
edges_runs | flag is 0; a and b are -1, fcount is 0 |
expand | owner and local_index are -1 past the sum of the counts |
hash_gather | items past the count are left as allocated (0) |
hash_hook | a slot past the live count reads and writes nothing |
scatter (fold pass) | an index past the live count reaches no place |
reduce | the last segment ends at the live count; a segment past segment_count is not written |
scan (SEGMENTS=device) | a segment past segment_count is not scanned or written |
expand (ELEMENTS=device) | counts and offsets hold element_count live elements, whatever the dispatch covers |
intersect_pairs | a triangle of A past the live ones has no entries |
intersect_cuts, intersect_unify | a slot past the live ones is not read or written |
intersect_retri | a triangle past the live ones becomes no triangle |
A new count. A kernel that makes a count writes it to an appended output's counter, which the op returns as
<output>_count and as the live count of the output (output.count); it carries its capacity and is a domain as it is.
compact writes the kept count, expand the sum of the counts, hash_gather the number of points listed, and
scan states its total in total (an i32 scan's total is a count: { count = scanned.total, capacity = ... }).
Capacity. When the node does not know a count it allocates for the bound it can state, and the kernel never writes
past it. compact, gather, scan, jump, loops* and edges_runs write at most as many slots as the capacity
they run over. expand holds at most n slots (n * k when no count exceeds k). hash_gather holds at most its
total param, which is the bound the node states (a pair list has no smaller bound than points times queries). In
those two the count buffer states the true count whatever the capacity: a count above .capacity is an overflow, the
first capacity slots are written and the count minus the capacity is how many did not fit. A node that states a
bound checks count <= capacity.
Storage buffers. A stage binds at most 8 on the web. A COUNT=device variant binds one more than the plain one,
and the table counts the variants that exist:
| Kernel | plain | device |
|---|---|---|
gather | 3 | 4 |
compact | 4 | 5 |
scan | 4 | 5 |
jump | 7 | 8 |
loops, loops_cut, loops_place | 6 | 7 |
edges_runs | 8 | 5 (heads), 8 (table) |
expand | 5 | 6 |
hash_gather | 6 | 8 |
hash_hook | 6 (hook, MATCH=normal), 1 (find) | 7, 2 |
edges_runs binds the buffers of its own stage in the device variant (when: "COUNT=plain; COUNT=device STAGE=table"),
which is how it stays at 8. abi.directLimit counts every buffer binding the schema declares, domain_count
included, so a kernel with a columns binding that adopts the form has one direct column fewer; none of these eleven has
one.
device.bound, device.live, device.guard
device.bound { value, capacity?, live? } answers value as a live count: a count Column that carries the most it can
reach, so a kernel runs over it (domain = count), a geometry carries it (class.count = count) and nothing is read
back. value is a one-word Column, arithmetic over counts, or a number. A number is already known and stays the number
unless live is true, which writes it to the device as the count, allocated the next power of two at or above it (a
list recorded over it runs again for every number inside that room and is recorded again for a number outside it).
A count held on the device with no capacity stated is allocated the room a cook of it measures: twice its count, in
powers of two, and never less than an earlier recording of the same cook allocated. The cook reads the count once. A
recorded list runs the count on the device clamped to that room, and holds a guard: a word that is 1 when the count
outgrew the room. The count a node can bound by what it reads (a triangle count by the vertices, pairs by the pairs
there could be) states capacity and holds no guard.
device.live { values, count } answers values carrying count as its live count. A kernel output declared
live: true in bindings.yaml carries the count its dispatch ran over the same way.
device.guard { value, flag, why, detail? } answers value once flag (a number, a one-word Column or a Field over
one) is 0. A cook that finds it otherwise refuses with why, or with what the lazy input detail raises. A recording
of the cook holds the word of flag, and a replay of it reads that word without waiting for a frame
(traffic().deferredReads): the cook that follows finds out whether the replay before it ran past the guard, lets the
recording go and records again. The replay that ran past a guard has handed out its answer by then: a count clamped
to its room, or the result for an input that is no longer a solid. A count that outgrew its room is recorded again with
four times the room (cookrun.counts().guarded counts the replays found to have run past a guard).
transfer.upload
values is a Record. A Number becomes a one-element Float column; a list of numbers a Float column; a list of
{x,y,z} a Vec column, a list of text (or { type = "String", values = {...} }) a column of the values; { type = "Int", values = {...} } a typed column (Float, Int, UInt, Vec2, Vec3,
Vec4, Color); a table of those a table of columns; a Column is held as the device run it has. columns (output)
holds the columns under the keys values gave them.
transfer.readback
columns is a Record of Columns, live counts, plain values (numbers, booleans and text answer themselves) or tables
of them, a Field (evaluated over the elements of the column it reads, then read; a Field that reads no column has no
length and is refused naming the form to use), or a Geometry or a Part, which answers the copy holding what its columns
held. A live count answers a Number, a Float or Int
column a list of numbers, a vector column a list of {x,y,z,w}, and values (output) holds them under the keys
columns gave them. The op waits across frames, and the play report names it as the point the plan waited. Anything
that waits on a readback value is a planning step.
cpu.call
fn (input) is called with args (input), and values (output) is what it returned, as a list in order. returns
(param) lists the type name of each result (Number, String, Bool, Function, List, Column, BufferRef,
Handle, or a procgen type) and a call that returns anything else is refused; a Handle in the results is released when
the result is. ceilingMs (param) refuses a call that ran longer (5000 when it is 0 or left out), counting the CPU
time the call runs for on the thread that makes it, in every coroutine it resumes, and in every task it starts
(task.spawn, task.defer, task.delay) or component hook it sets off while the call lasts, so a task it starts and
waits for is charged to it and one it leaves running is charged only what it ran before the call returned: the time it
waits on a frame, a program build, a read-back or a task, and the time its thread waits for a core on a busy machine,
are not counted. A device Column reaches the function as a
BufferRef { buffer, type, count, lanes, element }, and its data does not move. A Geometry reaches it with its columns
read to the CPU, or, with device (param) on, as it stands on the device. curves (param)
on hands an animated value among args to the function as the curve it is, and the function answers with what follows
it; off, the function gets the value it holds at the cook's time. pure (param) on states that the results are a
function of the arguments alone and the call changes nothing: a call whose arguments the plan already knows is made
while the graph is planned, and its results stand in the plan as constants.
cpu.read
cpu.read is cpu.call (fn, args, returns, ceilingMs, device, output values) for a function that reads
state the graph does not hold, such as entities in the scene. digest (input) is a function called with the same
values that answers a value standing for what fn reads; the graph's live digest folds it in, so a Generator cooks the
graph again when that state moves. It takes no curves or pure.