Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit2e536ca9a9

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 .procNode or .procGraph names 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({}).material is g.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, names and material is 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>") and asset.resolve("~.<name>", type): ~ there is the nearest .package folder, wherever the node sits in it.
  • g.op.<name>(args) (and proc.op.<name>) adds a node of an op, for library nodes built on the ops. An op that is a graph built with g:node (the procNodes/ 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>, Record with its fields. opts takes min, max, options, description, optional, plain and type for a default that does not state its type or no default at all. An empty table default with a stated type is 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 .y and as .z.
  • An input declared plain = true, an input with options, and an input of type Bool or String is a plain value while the graph is built: g:input answers the value itself, if, comparisons and loops over it are allowed, and it passes into calls as a constant. plain = true is 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 in graph.plain, and each such declaration in graph.inputs[name].plain = true.
  • Sockets take + - * / % ^ // .. and unary minus, math.* (every function of the math table but random and randomseed, which a graph refuses: they draw from state outside it, and field.rand, field.random and rand.unit draw from a seed) and vector.* (each a math.<name> node; a function that returns several values, math.frexp and math.modf, returns the node's outputs in order, as it does on numbers; vector.create over sockets is math.vec2, math.vec3 or math.vec4 by its component count), x.name, x.name = v and x[name] = v for a computed name (which rebind x), x[class][name], x[n], and for index, item in list do ... end. A source loaded through builder.prepareSource reads plain math, vector and rand over sockets unchanged; define loads a node or graph entry that way when it names them.
  • vector.create has 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 } (what math.vec2 and math.vec4 make 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) and g.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 a pure io.luau call 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 a for over its answer may add a column per name (schema["s_" .. name] = specs[name] over specs.keys), and a step body writes them by the same names.
  • g:check(cond, message): a cond known while the graph is built refuses the build with the message (a true one records nothing); a cond that 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), and forInputs states 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 for body over sockets keeps one element (rings[#rings + 1] = v, table.insert(rings, v)): the body is one pass for every item. builder.check refuses it with the code socket-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 a for body 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 for body is one pass that stands for every item. index is 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 in scope.carries[id] = { init, result } and the refs loop_index, loop_carry (the value at the start of a pass) and loop_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 as g: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 }) and g.clock.entity answer where the graph stands in the scene it is cooked in: each is a construct.scene node 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) and g.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 }; fn returns the state only. The fold runs n passes and ends early at the first pass whose stop holds.
  • g.element is the element a Field evaluates over: g.element.id (Int), g.element.P (the position leaf that fieldc.withPosition substitutes, also written g.element.point.P) and g.element.<class>.<column> for a column of the domain the field stands in. Each read is a socket, so g.element.id * 1.0 is a math node.
  • rand.unit(seed, ...scope, id) (a global in a node source, also builder.rand.unit) is the Float field of unit draws keyed by the seed, the scope (graphId, nodeId, channel, or graphId, compositePath, nodeId, channel) and the element id, last (g.element.id). It adds an io.luau node { module = "@builtin::systems.procgen.proc.fieldc.leaves", fn = "rand", args = { seed, { ...scope }, id }, returns = { "Any" } } and answers its value.
  • geometry.names[class] lists the attribute names a class holds (a list socket the plan answers); geometry[class].count = n assigns the domain count, so count is no column's name (a value per element written under it is refused); geometry.material = m assigns the material. names is 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, as count and keys are. material is 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, .delta and .duration are sockets; .fps, .frames, .loop and .realTime are the timeline's values.
  • Luau's < <= > >= == ~= over a socket, and and, or, not, if and while that test one, have no value while the graph is built. builder.check(source) names each one with the g.* form to write, and define holds a node or graph entry to it. and and or test their left operand alone, so plain or socket passes. 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 code socket-table-index, which names g.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) answers graph while its plain inputs hold what the build used, else the graph built again from graph.source with the values inputs states (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_SOURCE of 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 answers nil, reason when 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, so if input == nil then ... end in a node source is decided by what the call wired. The declaration states wiring = true and the graph records the wiring it was built for in graph.wiring[name]; forInputs builds the graph again for a cook given no value for such an input (values[name] == false states none in rebuild and withInputs).
  • builder.rebuild(graph, values) builds again unconditionally; builder.withInputs(values, fn) runs a function that calls proc.define and 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 through forInputs with 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, and graph: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.edit replaces a node it writes into by a copy of its own (the node, its inputs and its params) before it writes, and builder.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.

constructinputsparamsanswers
membervalue, namethe member of value
member_setvalue, name, tovalue with the member written
itemlist, indexthe item
columngeometry, class, namethe column read (a read of count is the domain count)
column_setgeometry, class, name, valuethe geometry with the column written
column_removegeometry, class, namethe geometry without the column
count_setgeometry, class, countthe geometry with that class's domain count set to count; a value per element is refused (no column is named count)
material_setgeometry, materialthe geometry with its material set
namesgeometry, classthe List of attribute names the class holds, answered at plan
hasgeometry, class, namea Bool answered at plan: whether the class holds the column
checkcondmessagenothing; a cond that is false when cooked is an error stating message
comparea, bop (eq neq lt le gt ge)a Bool
wherecond, a, ba where cond holds, else b
pickchoice, alternativesthe 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_samplestep, valuethe value of an animated value at the step's time
step_betweenstep, indexthe 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:

refstands 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 }, enginedeclared 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, and stop when 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 whose parent is its fold. Its nodes read fold_before and fold_after; result is 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.