Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commitdd348ac6c3

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

OpRunsDoes
device.kernelgpuRuns a .computeShader over a domain of elements and returns what it wrote as columns
transfer.uploadcpu_to_gpuTurns CPU numbers, lists and tables into device columns
transfer.readbackgpu_to_cpuTurns device columns and live counts into CPU values, a frame or more later
cpu.callcpuCalls a Luau function with declared return types and a time ceiling
cpu.readcpuA cpu.call over state outside the graph, with a digest of what it reads
device.boundgpuAnswers a count as a live count that states the most it can reach
device.livegpuAnswers a column carrying a count, so a reader of it takes the live elements only
device.guardgpuHolds 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 columns binding takes a table of Columns, packed on the device into one buffer in the order the binding's order field 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 states order; one that reads them by NAME_col_<column> needs none.
  • specialize takes the table fieldc.walkSpec(step, opts).specialize states as it stands (the walk kernel's WALK_STEP, WALK_STEP_HI, WALK_DONE and WALK_PRELUDE), and the resources the spec runs over come from fieldc.bindSpec(spec): its cols (a table of columns already packed on the device) binds to the kernel's columns binding and its u (the numbers the chain carries, a device buffer) to its read buffer binding. 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.columns is 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 unbound columns binding 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>_x to <param>_w. count, time and frame are names any node may give; a kernel that declares none of them leaves them out.
  • A Texture binds its texels column, and sets <name>_width and <name>_height where the shader declares them.
  • domain is 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 a buffer binding named domain_count. A shader that declares domain_count over a plain number gets the number in it. The live-count form is stated in "Over a live count" below.
  • An output with count or capacity is allocated before the dispatch. An appended output comes back as a Column (its _capacity and _liveCount set) 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:

KernelInert value of a slot past the live count
gatherdst is zeros
compactinverse is -1; indices is -1 past the kept ones
scandst 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
loopsstage clear writes every slot of the dispatch; stage link: next is the slot itself, head is 0
loops_cuthead is 0, cut is the slot itself
loops_placering is -1, pos is 0
edges_runsflag is 0; a and b are -1, fcount is 0
expandowner and local_index are -1 past the sum of the counts
hash_gatheritems past the count are left as allocated (0)
hash_hooka slot past the live count reads and writes nothing
scatter (fold pass)an index past the live count reaches no place
reducethe 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_pairsa triangle of A past the live ones has no entries
intersect_cuts, intersect_unifya slot past the live ones is not read or written
intersect_retria 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:

Kernelplaindevice
gather34
compact45
scan45
jump78
loops, loops_cut, loops_place67
edges_runs85 (heads), 8 (table)
expand56
hash_gather68
hash_hook6 (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.