Builtin Library
README.md
proc.builder
What a graph builder hands the author, in the call form: proc.define(function(g) ... end).
g.<name>(args)adds a node of the node asset the name resolves to, by asset identity: the nodebox and the node's folder name (g.points.disc), a path (g["/zero/source/fx/ring.procNode"]),~(the nearest package, effect or world root:g["~/ring"]), a guid, or a short name (g.disc). A path,~name or identity ending in.procNodeor.procGraphnames an asset of that kind alone; one without the suffix names either, so where a node and a part share a name it states the suffix. A name that matches two nodes, and a name that matches none, are errors that name the candidates. Each call is recorded in the graph by the node's guid. Arguments are a table naming inputs or positional values in the node's input order.- What a call answers: a node with one output answers that output's socket, and reading that output by the name
the node declares for it answers the same socket (
g.material.pbrMat({}).materialisg.material.pbrMat({})); any other name is a member of the value, and for a node whose output the source states to be a Geometry, any name but a class,namesandmaterialis refused where it is read, naming the output. A node with several answers a record of sockets by output name (local f = g.core.normals.face(geo); f.geometry, f.area), a node with none answers nothing. The record does not depend on declaration order. The record handed whole to an input that takes one value is refused where it is handed, naming the outputs to read (.value,.report). - A name is called this way only for a node written in the call form (
builder.callsByName(path)says which). A name no node answers to is refused naming the nearest names a call resolves. - A node's own source reaches its package's modules and assets with
require("~.<name>")andasset.resolve("~.<name>", type):~there is the nearest.packagefolder, wherever the node sits in it. g.op.<name>(args)(andproc.op.<name>) adds a node of an op, for library nodes built on the ops. An op that is a graph built withg:node(theprocNodes/of the procgen package, registered under their graph ids) is refused at the call, naming its folder and the node written in this form that answers to the same name ('g.op.points.filter' is .../procNodes/pointsFilter.procNode, a graph built with g:node, ...; call g.points.filter). An id no op carries is refused naming the ops that start the same way.g:input(name, default, opts)is typed by the default:Float,String,Bool,Vec3,Color,List<T>,Recordwith its fields.optstakesmin,max,options,description,optional,plainandtypefor a default that does not state its type or no default at all. An empty table default with a statedtypeis that type's empty value (g:input("entities", {}, { type = "List<Entity>" })). A vector written as a plain table reads its components by name whichever form it was written in, the reading an op's params take:{ 0, 0, 0, 1 }answers.w, and a pair ({ a, b },{ x, y }or{ x, z }) answers its second component as.yand as.z.- An input declared
plain = true, an input withoptions, and an input of type Bool or String is a plain value while the graph is built:g:inputanswers the value itself,if, comparisons and loops over it are allowed, and it passes into calls as a constant.plain = trueis how a count, a vector, a record or a list that chooses the graph's structure is declared (g:input("storeys", 5, { type = "Int", plain = true }),g:input("edges", { { bays = 3 } }, { plain = true })): the graph is built again for each value it is given, it is not animated, and it takes no per-element field. Any other number and vector input is a socket, which changes without a build and takes animated values and fields. A table value reaches the build as a frozen copy. The graph records the plain values it was built with ingraph.plain, and each such declaration ingraph.inputs[name].plain = true. - Sockets take
+ - * / % ^ // ..and unary minus,math.*(every function of themathtable butrandomandrandomseed, which a graph refuses: they draw from state outside it, andfield.rand,field.randomandrand.unitdraw from a seed) andvector.*(each amath.<name>node; a function that returns several values,math.frexpandmath.modf, returns the node's outputs in order, as it does on numbers;vector.createover sockets ismath.vec2,math.vec3ormath.vec4by its component count),x.name,x.name = vandx[name] = vfor a computed name (which rebindx),x[class][name],x[n], andfor index, item in list do ... end. A source loaded throughbuilder.prepareSourcereads plainmath,vectorandrandover sockets unchanged;defineloads a node or graph entry that way when it names them. vector.createhas the width of its component count whether its arguments are sockets or numbers: two numbers answer the Vec2 value{ x, y }and four the Vec4 value{ x, y, z, w }(whatmath.vec2andmath.vec4make of numbers), and three a Luau vector.orient = vector.create(0, 0, 0, 1)writes a Vec4 column.g.compare(a, op, b),g.where(cond, a, b),g.pick(choice, alternatives),g:fold(n, init, fn, opts),g:state(name, columns),g:step(dt, substeps, fn),g:check(cond, message),g.has(geometry, class, name)andg.copy(socket)record the constructs the evaluator knows.g:state(name, columns)states the columns a step keeps,{ [name] = { type, count } }, as values the plan knows: literals and inputs, records and lists of them, and what constructs and apureio.luaucall make from them. A pure call's answer stands even when its arguments are cooked (a call reading the attribute names of a source's points), so aforover its answer may add a column per name (schema["s_" .. name] = specs[name]overspecs.keys), and a step body writes them by the same names.g:check(cond, message): acondknown while the graph is built refuses the build with the message (a true one records nothing); acondthat reads a socket is recorded for the plan or the cook to answer. The build of a node's own defaults, which is what indexes the node, holds such a refusal instead of raising it (graph.refusals), andforInputsstates it where the node is called or cooked as it stands, so a check over a plain input fires when the call gives it the refused value.- A plain Luau table filled inside a
forbody over sockets keeps one element (rings[#rings + 1] = v,table.insert(rings, v)): the body is one pass for every item.builder.checkrefuses it with the codesocket-loop-append, naming the form to write: a local declared before the loop and assigned inside it, read after the loop, where it is the list of every pass's value. g.copy(value)of a record or a list written in the graph (g.copy({ total = 0 })) is a value of its own that member writes rebind, so aforbody that fills it carries it from pass to pass.- A class view made with a socket key (
names[i]) and read as a value is a node of the scope it was made in, whichever loop body first reads it. - A
forbody is one pass that stands for every item.indexis the item's place (a socket, from 1). A value built outside the body that the body reads or assigns (geometry[class][name] = v,record.name = v,record[name] = v) is carried: each pass starts from what the pass before left, the first from the value as it stood before the loop, and after the loop the value is what the last pass left. The loop records the carries inscope.carries[id] = { init, result }and the refsloop_index,loop_carry(the value at the start of a pass) andloop_result(the value after the loop). A value the body only reads reads as it was. The emitter writes a carrying loop as it was authored: a local that starts asg:copy(value), the body's assignments to it, and the loop reading that local after it (proc.emit). g.clock.placement(16 numbers, column after column),g.clock.inverse,g.clock.rotation({ x, y, z, w }) andg.clock.entityanswer where the graph stands in the scene it is cooked in: each is aconstruct.scenenode that reads the scene the cook is handed (a graph standing on no entity has the identity placement and no entity).g.transform.point(p),g.transform.direction(d)andg.transform.rotation(q)carry a world position, direction or quaternion into the graph's space through the inverse placement; each takes plain values, sockets or Fields.g:fold(n, init, fn, { stop = "changed" })or{ stop = function(before, after) return <Bool> end };fnreturns the state only. The fold runsnpasses and ends early at the first pass whose stop holds.g.elementis the element a Field evaluates over:g.element.id(Int),g.element.P(the position leaf thatfieldc.withPositionsubstitutes, also writteng.element.point.P) andg.element.<class>.<column>for a column of the domain the field stands in. Each read is a socket, sog.element.id * 1.0is a math node.rand.unit(seed, ...scope, id)(a global in a node source, alsobuilder.rand.unit) is the Float field of unit draws keyed by the seed, the scope (graphId, nodeId, channel, orgraphId, compositePath, nodeId, channel) and the elementid, last (g.element.id). It adds anio.luaunode{ module = "@builtin::systems.procgen.proc.fieldc.leaves", fn = "rand", args = { seed, { ...scope }, id }, returns = { "Any" } }and answers itsvalue.geometry.names[class]lists the attribute names a class holds (a list socket the plan answers);geometry[class].count = nassigns the domain count, socountis no column's name (a value per element written under it is refused);geometry.material = massigns the material.namesis read by that name on any socket: what is read off it by a class (geometry.names.point) lists the names, and any other read (record.names[2],record.names) is the member a record holds under that name, ascountandkeysare.materialis assigned by that name on any socket.- A member, an item, a column or the names of a value built outside a fold, step or stop body that the body reads first is a node of the scope the value was built in: the body and every later fold read the one value, which the plan writes once. A read keyed by a socket of the body (a fold's index) stays in the body.
g.clock.time,.frame,.deltaand.durationare sockets;.fps,.frames,.loopand.realTimeare the timeline's values.- Luau's
< <= > >= == ~=over a socket, andand,or,not,ifandwhilethat test one, have no value while the graph is built.builder.check(source)names each one with theg.*form to write, anddefineholds a node or graph entry to it.andandortest their left operand alone, soplain or socketpasses. The check follows names block by block as Luau scopes them: a local, a loop variable or a function parameter is its block's own, and a parameter of a function that is not a fold, step or stop body is not known to be a socket (a comparison over one is refused when the graph is built). Plain inputs are not sockets to the check. A plain Luau table indexed by a socket (LANES[values.type]) answers nil while the graph is built, so the check refuses it with the codesocket-table-index, which namesg.pick. - A list of values holds a nil as a value:
{ a, nil, b }is three, and the second is a constant of nothing.
Building a graph for other plain values
A graph whose plain inputs change structure is built again for the values a cook was given.
builder.forInputs(graph, inputs)answersgraphwhile its plain inputs hold what the build used, else the graph built again fromgraph.sourcewith the valuesinputsstates (an input it leaves out takes its default); values are compared by content, a record or a list included, and the result is cached by the source text and the values, so a node written again is built again.builder.KEPT_BUILDS_PER_SOURCEof a source's builds are kept, the ones used last. A build for other values keeps what the cook already made: only the nodes the new values change cook. A plain input given an animated value, a per-element field or a value held on the device is refused by name. It answersnil, reasonwhen the source cannot be built again or a value is outside the input's options.- An optional input with no default (
g:input(name, nil, { optional = true })) is nil while the graph is built for a call that leaves it unwired, and its socket otherwise, soif input == nil then ... endin a node source is decided by what the call wired. The declaration stateswiring = trueand the graph records the wiring it was built for ingraph.wiring[name];forInputsbuilds the graph again for a cook given no value for such an input (values[name] == falsestates none inrebuildandwithInputs). builder.rebuild(graph, values)builds again unconditionally;builder.withInputs(values, fn)runs a function that callsproc.defineand gives that graph the values. A node asset called with a plain value records it as a constant param; the evaluator reads a node's graph throughforInputswith those params.
What a build resolves once
A build resolves each name once. The first call of a name g.<name>(...) in a build finds the node asset the name
answers to; the build keeps the asset, its inputs and outputs, and the envelope its calls record, and every later call
of that name in the same build takes them. Each building frame (a graph source, or a node source loaded by a call)
holds its own, by the graph's source and the name, on the thread that runs the build; a builder function written inline
(proc.define(function(g) ... end) with no source file) has no frame, and the graph it builds holds the memo until
run returns. The memo ends with the frame, so a node written between two builds is resolved again by the next, and builds on other threads share nothing. The Info of
a node asset is kept by the node graph it was read from, and the line of each call is kept as a number that siteOf
states as path:line where it is read. builder.setResolveMemo(false) switches all three off for the builds after
(the answer is the setting before), and builder.resolveCounts() answers the calls that resolved a name (resolved)
and the calls a kept answer served (hits).
What a build is made against
A graph source that has a base is built against it. The base of an entry source is the graph the last
proc.takeHandOff of its folder gave a Generator (builder.setBase(graph), builder.baseOf(entry)), held weakly.
When the run of a build ends, a pass compares every node the run made with the base's node of the same id (compareNode
of proc.eval.replan, and the node's op field by field). A node that is equal is replaced in graph.nodes by the
base's own table, which is frozen, along with the tables under its inputs and params; a node that differs, or that the
base does not hold, is frozen where it is. The scopes are compared and the ones that differ are listed; they stay the
build's own tables. The graph states the result in _diff: changed (the ids of the nodes that differ or are new),
removed (the ids of the base's nodes the build did not make), scopes (the ids of the scopes that differ), added,
the counts of the nodes and scopes, against (the base, held weakly) and the edit counts of both graphs when the pass
ran. The planner (replan.dirty) reads it while the graph and the base stand as they did then, and compares only the
nodes it lists.
- A node that keeps the base's table keeps the place the base gave it in the order the nodes were made in. When this
build made it elsewhere, the graph states the new place in
_moved;builder.seqOf(graph, id)answers the place a node holds, andgraph:toJSON()states the nodes with it (builder.documentNode(graph, id, node)is one node as the document states it), so a document and an emitted source are those of a build that made every node itself. - A table two graphs share is frozen, so a write into one raises.
proc.editreplaces a node it writes into by a copy of its own (the node, itsinputsand itsparams) before it writes, andbuilder.againstCounts()counts those copies beside the passes run (passes), the nodes filed from the base (reused), the nodes listed (changed) and the time the passes took (ms). builder.setBuildAgainstBase(false)switches the pass off for the builds after (the answer is the setting before). A build of a source with no base, a build for other plain values (forInputs,rebuild) and a build of a node asset are the graph their run made.
What a build reads
A node or graph source is compiled with builder.compile(src, "@" .. entry) and run as a build with
builder.building(entry, fn, ...). While a build runs, the source sees a vfs whose read, readAsync and
readBounded note each file for that build, and builder.read (proc.read) notes a file read from a module the
build calls into. When the build ends, the files it read are recorded against its entry source, replacing what its
earlier builds read: builder.readsOf(entry) lists them and builder.readersOf(file) the entry sources whose last
build read a file. A write of such a file is a proc.sourceChanged of each of those sources. builder.forget(folder)
drops the source text and the built graph the builder holds for a node or graph, so the next build that reaches it
runs its source again. The modules a build requires are recorded by the engine, which tells the node or graph type
through onDependencyChange when one of them re-runs.
The wiring of each run of graph.module
graph.module binds its wiring into the builder (builder.bind) each time its body runs, with the metatable of the
graphs that run makes as helpers.graph, and the builder keeps that wiring on the metatable. A graph is wired by the
run that made it: builder.run declares its outputs, a value is wired into its refs, and rebuild builds an inline
graph again, each through the wiring of the graph's own run. The body runs again whenever the require cache lets go of
the module while a module that took its functions keeps them (proc.define is graph.module's define as proc
loaded it), as a reset of the scripting state to a mark taken before the module loaded does; the graphs of both runs
are then built side by side.
What a graph records
A call-form graph states surface = "call". Each node it makes carries call = true, seq (the order it was made
in, which the emitter writes it back in) and scope. A node asset is op = { __ref, type = "procNode", name }; an
op is its id string; a construct is op = "construct.<kind>" with construct = <kind>. graph.scopes holds each
for, fold, stop and step body, graph.states each g:state, graph.plain the plain input values.
Each call and scope also records the line of the graph's source it was written on, path:line
(builder.siteOf(graph, id)), in a table the graph's document leaves out, so where a call sits moves no hash. The
plan carries it onto each call it writes out (plan.flat.origins[<flat id>].site, the graph's own calls in
plan.flat.sites), and a cook's failure is read at it: describeFailure(flat, err) of the eval module
(@builtin::systems.procgen.proc.eval) answers
<path>:<line>: g.<call> failed: <why>, the line being in the deepest source the person can edit.
The inputs of a construct node are refs (a constant is { kind = "const", value }); params are constants.
| construct | inputs | params | answers |
|---|---|---|---|
member | value, name | the member of value | |
member_set | value, name, to | value with the member written | |
item | list, index | the item | |
column | geometry, class, name | the column read (a read of count is the domain count) | |
column_set | geometry, class, name, value | the geometry with the column written | |
column_remove | geometry, class, name | the geometry without the column | |
count_set | geometry, class, count | the geometry with that class's domain count set to count; a value per element is refused (no column is named count) | |
material_set | geometry, material | the geometry with its material set | |
names | geometry, class | the List of attribute names the class holds, answered at plan | |
has | geometry, class, name | a Bool answered at plan: whether the class holds the column | |
check | cond | message | nothing; a cond that is false when cooked is an error stating message |
compare | a, b | op (eq neq lt le gt ge) | a Bool |
where | cond, a, b | a where cond holds, else b | |
pick | choice, alternatives | the alternative the choice names: by option name, by a list position (a false choice the first, a true one the second), or by false and true keys | |
step_sample | step, value | the value of an animated value at the step's time | |
step_between | step, index | the fraction (0 to 1) of the step's movement that source index of the step has covered; the evaluator defines it |
A call that has several outputs names them in refs: { kind = "node_output", node, output = "<name>" }; a node with
one output has output = nil.
Refs
The v1 refs (const, graph_input, node_output, list, record, animated) and:
| ref | stands for |
|---|---|
loop_item { loop }, loop_collect { loop, ref } | the item of a for scope, and the list of ref over every item |
fold_value { scope }, fold_index { scope }, fold_result { scope } | the value and the index (from 1) of a fold pass, and the value after the last pass |
fold_before { scope }, fold_after { scope } | the state before and after a pass, inside a fold's stop function; scope is the stop scope |
step_state { scope }, step_index { scope }, step_result { scope } | the state record, the step record (dt time graphTime index first start, with step_sample and step_between), and the state the step reached |
state { name }, clock { field }, engine | declared state, the clock's field, the engine API namespace |
element { field, class? } | the element a Field evaluates over: field = "id" its index, field = "P" its position leaf (no class), else the column field of the domain class |
Scopes
graph.scopes[id] is { kind, parent, seq, ... }:
for:list,item.fold:count,init,result, andstopwhen it states one:"changed"(the pass ends the fold when the state it produced equals the one it read) or{ scope = <id of a stop scope> }.stop: a scope whoseparentis its fold. Its nodes readfold_beforeandfold_after;resultis the Bool ref the fold stops on. It is evaluated after each pass.step:dt,substeps,result.
The plain table of a graph and the plain = true flag of an input declaration state which inputs are build-time
values.
Part of @builtin::systems.procgen.proc.