Split out of world-3d.md, which flagged these two halves as the natural cut. What the
:stats onHUD reads, and where a frame’s draw calls actually happen. The buffers a frame draws into, a second fragment output and a screen pass that reads one back are render-targets.md.
:stats on HUD reads)| What | Where |
|---|---|
The scene’s render objects (on PView, not MapView) |
PView.instancer (an InstanceList) + PView.back (the DrawList) — protected, null before the first draw builds the env-bound lists; fork accessors instancer()/drawlist() (// addon:) |
| Frustum culling (fork) | Upstream tests no visibility at all: every slot in the tree is a draw call every frame. Fork: PView.envsetup feeds back from the instancer through PView.frustum, which puts in back only the slots whose FastMesh.bounds() reach the frustum of the slot’s own Homo3D.cam × Homo3D.prj, when PView.frustumcull() says so (MapView: GSettings.frustumcull). Run once a frame from PView.draw after instancer.commit. The shadow list is fed by the instancer too, so what the camera leaves out still casts. An InstanceBatch is tested as the box around all its members (InstanceList.batchbox, kept until a member comes, goes or moves); a slot with no Homo3D.loc, a non-FastMesh object and a batch of anything else are always drawn. MapView.Terrain no longer culls cuts out of the tree |
| Scene tree size | RenderTree.stats over nleaves/nslots; fork getters nleaves()/nslots() |
| What makes one batch | InstanceList.InstKey: the object’s instanceid() (a FastMesh is its own), the un-instanced state pipes by identity and the Instancer of every instanced state slot. Upstream nothing in it says where a slot stands, so one mesh in one state is ONE draw call over the whole scene and can never be left out for being off screen — 13k palisade pieces were ~10 M triangles a frame with the camera on the ground. Fork: the key also holds the map grid (MCache.cmaps × tilesz) the slot’s Homo3D.loc translation falls in, and Instance.update/Sole.update re-key a slot whose location crossed into another grid, so a batch is one grid’s and costs a draw call per grid it covers |
| Levels of detail (fork) | Upstream has none: a mesh is drawn with every triangle at any distance. A Model is a vertex array plus an index buffer, so a coarser level of a FastMesh is one more Indices over the same vertices — and, instanced, over the same instance buffer. Fork: FastMesh.lod(level) makes the levels once, on a Defer thread, the first time they are asked for (quadric edge collapse over the mesh with its vertices welded at each point, each corner handed back the original vertex whose normal is nearest, io.brodgar.perf.MeshLod; a mesh of loose little pieces gets none), and FastMesh.Instanced.lod(level) rebuilds its Model over them, which instupdate carries to every client of the batch — the shadow list too. PView.frustum picks the level per batch by screen size and leaves out a batch too small to see, and only while frustum culling is on, since that pass is what picks them. Each size is weighed times FrustumList.lodbias, the Performance page’s Objects ▸ LOD distance (persisted as lodbias), which :lodbias <x> sets too; :lod on|off switches the levels, and :lod prints the counts. A slot drawn on its own — the ground among them — is always drawn whole |
| Batching effectiveness | InstanceList.stats over nuinst+nbatches(ninst) ninvalid nbypass; fork getters at. Written on the render side ⇒ a read may be one frame stale |
| Draw slots (“draw calls”) | DrawList.stats is an interface default; the real count is GLDrawList.btsubsize(root). Fork: DrawList.drawslots() defaults -1 = “does not count”, overridden in GLDrawList |
| VRAM pools + shader programs | stats_obj/stats_mem, indexed by the package-private MemStats enum and kept by GLObject.setmem — SHADERS and PROGRAMS among the pools, counted by ShaderOb.create/ProgOb.create. The HUD reads them as text through Environment.stats(Collection) (GLEnvironment’s V-Obj/V-Mem lines; Environment.Proxy forwards it). Fork: the pool names as String[] mempools() + memobjects(i)/membytes(i), and numprogs(), the programs ptab holds (// addon:) |
| Render-state slots (process-wide) | State.Slot.numslots() |
Gotcha. Everything above except State.Slot.numslots() needs a live scene: ui.root.findchild(MapView.class)
is null before the world loads, instancer/back are null before the first draw, and a non-GLEnvironment
backend has no VRAM or program counts at all. Report an absent value, never a 0. ⚠️ On GLX and Cocoa
ui.getenv() is a GLProxy, not the GLEnvironment: an instanceof GLEnvironment test on it fails there and
nowhere else, so take GLProxy.back() first.
| What | Where |
|---|---|
| The 3D scene draw boundary | PView.draw → instancer.commit(out) then maindraw(out) = back.draw(out), the draw-list dispatch; resolve(g) + list2d.draw(g) follow it. MapView.maindraw prepends drawsmap → smap.update(out, slist) — the entire shadow render in one call; fork: one frame in two (The shadow map) |
| Batched submission (per frame) | GLDrawList.draw(Render) walks the sorted DrawSlot list on the UI/dispatch thread: gl.bglCallList(cur.compiled) per slot = one draw call each, and a program bind wherever cur.prog changes (the list is sorted by program, so binds ≪ calls means the sort works) |
| Slot compile (rare, not per frame) | GLDrawList.SlotRender.draw(Pipe,Model), from the DrawSlot ctor — the only place the Model is in hand; glupdate bakes GLProgram.apply + settings into compiled. Fork: DrawSlot.nverts/ntris are computed here, once |
| Immediate submission | GLRender.draw(Pipe,Model) — every 2D blit and ephemeral model, state.apply then glDrawArrays/glDrawElements(Instanced) |
| Immediate program binds | Applier.apply2 and apply(BGL,Applier) — the two GLProgram.apply sites that run per frame |
| Geometry per model | Model: mode (Mode), n (vertices, or indices when ind != null), ninst. Triangles = n/3 (TRIANGLES) or n-2 (STRIP/FAN), × ninst; POINTS/LINES contribute none |
Gotcha. The GL calls are written at slot-compile time and replayed by BufferBGL on the render thread —
neither is a per-frame count. Instrument the two dispatch seams above (GLDrawList.draw, GLRender.draw);
the replay loop is far too hot to touch.
Gotcha — a filler holds what it fills, for as long as the buffer lives. A STATIC buffer or texture is
filled once per environment: GLEnvironment.prepare calls its DataBuffer.Filler when a slot drawing it is
compiled (GLDrawList.add → the DrawSlot ctor → env.prepare(Model) / env.prepare(mod.ind), and a texture
as the slot’s state is compiled), copies the answer into its own FillBuffer, and never asks again unless
another environment prepares the same object — a new GL context (NEWTContext disposes the old environment
and makes a new one when the context changes). So the arrays a filler reaches are a copy of what is on the GPU,
kept on the heap for nothing: DataBuffer.Filler.of(array) and a lambda over one keep theirs for the life of
the Model/Texture2D. Upstream’s own way out is a filler that makes its data from a source when asked and
lets it go after: TexL, RUtils.CubeFill and CrackTex drop theirs in Filler.done(). ⚠️ done() is
called for a texture alone (GLTexture.Tex2D.create, after every level): a buffer’s filler lets its array go
inside fill. ⚠️ The fill happens when the slot is compiled, in view or not — FrustumList.add puts every
slot in the draw list before taking an off-screen one out — so adding a slot and removing it at once puts its
object on the GPU without drawing it.
Gotcha — everything prepared since the last frame is uploaded before the next one, all of it.
GLEnvironment.prepare(…) appends to one prep render that process(GL) replays whole ahead of the frame’s own
commands, and nothing bounds how much it holds: a frame that adds a few megabytes of new meshes and textures
spends 10–20 ms of the render thread on them, which is the frame rate going from 200 to 40. What is prepared is
used in that same frame, so a bound cannot sit in the environment; it sits with whoever adds to the scene. Fork:
io.brodgar.perf.UploadBudget — the render thread reports the time it spent replaying prep
(GLEnvironment.process → UploadBudget.rendered), UILoop.Frame.run gives each frame its share, and the
remembered ground’s merged grids, its click map and the far ground’s new builds pay for what they upload out of
it. ⚠️ The fill runs on the thread that compiles the slot — the UI thread for a slot.add — and so does a
filler’s conversion: RUtils.CubeFill converted the six faces of a cube texture in fill, the first frame the
cube was drawn (water’s sky, ~80 ms). Fork: CubeFill.mktex() converts them where the texture is made.
Gotcha — ZONED lighting uploads its cell lists every time it compiles. Lighting.LightGrid.compile makes a
new GridLights with two new textures, the 64³ cell lists (half a megabyte) and the light data, and disposes
the last pair; it compiles whenever the lights or the projection move (world-3d.md), which a
flickering light does with the camera standing still. Fork: the cell-list texture is kept while the cell lists
come out the same.
Gotcha — a draw list’s settings are compiled again whenever a state they depend on changes, and the frame’s own
state changes every frame. GLDrawList.update(Pipe, int[]) runs DepSetting.ckupdate for every setting whose
slots the change touches, and Setting.update compiles that setting afresh into a new BufferBGL: a
UniformSetting is one uniform, a PipeSetting one pipe state — a command or two. PView.tick sets
FrameInfo (the frame’s time) on conf every tick, so every setting that reads the time is compiled again
every frame, and the camera and the lights do the same while the camera moves. ⚠️ new BufferBGL() holds 128
commands, a kilobyte of garbage a setting a frame — measured, a quarter of what the UI thread allocates with the
camera still. Fork: Setting.update starts its buffer at 4 (// addon:); it grows as it must, and trim()
keeps what was used.
| What | Where |
|---|---|
| A program’s life | GLEnvironment.getprog(hash, ShaderMacro[]) looks the macro set up in ptab and calls GLProgram.build on a miss — on the calling thread, which for a slot compile is the UI thread: the macros write the two GLSL sources (ProgramContext → construct), and GLProgram’s constructor derives the uniform, attribute and fragment-output tables from them. The GL objects come later: GLProgram.glid() mints the ProgOb and its two ShaderObs, each an env.prepare into the next process()’s prep buffer, where ShaderOb.create compiles and ProgOb.create attaches, binds the locations and links |
| What a link costs, and why it is the login’s GL thread (fork) | Measured on a GTX 1660 SUPER (NVIDIA 591.86): glLinkProgram 10–35 ms per program, 96 programs in the first second after the first drawn frame — 1.0–1.1 s of the GL thread, which the UI thread waited out on the frame fence (Frame.syncwait, two dwait stalls of ~300 ms) — and 13–17 ms for every material first seen in play. glCompileShader is nothing (180 in 11 ms): the driver compiles at the link. The generated GLSL is byte-identical from one run to the next (checked over 97 programs), so the driver’s own disk cache should have answered and did not |
| The program binary cache (fork) | ProgramCache: a linked program kept in the driver’s binary form (GL_ARB_get_program_binary, core 4.1) under savedata/shaders/<driver>/<key>.bin, one folder per vendor\|version\|renderer so a driver update is a fresh folder and never a rejected binary. Key = GLProgram.cachekey, SHA-256 of both sources plus every glBindAttribLocation/glBindFragDataLocation the link is given. Read in GLProgram.build, on the thread that generated the sources, never on the GL thread; hit in ProgOb.create: glProgramBinary + GL_LINK_STATUS, no attach, no link, ~0.4 ms; miss: GL_PROGRAM_BINARY_RETRIEVABLE_HINT, the link as before, glGetProgramBinary (a memcpy) and a write on the cache’s own thread (tmp + atomic move); reject (a corrupt file): forgotten, linked as before. Off where Caps.progbinfmts (GL_NUM_PROGRAM_BINARY_FORMATS) is 0, where Client.setupres set no root, or with -Dhaven.progcache=false. Measured with it: 97 hits, 0 rejects, 86 creates in 42 ms, no dwait stall left. The warm list: a read is a file per program on the building thread, which for the hundred or so programs the first frames in the world compile is the UI thread; every program a session loads is listed in savedata/shaders/warm.txt (<driver>/<key>, in order of first use, rewritten at most every 5 s), and ProgramCache.preload, from LoginWarmup, reads the last session’s into memory before the world is entered (up to 64 MB; what nothing takes within a minute of the first take goes). The first session after a fresh cache writes the list and reads nothing |
| The wrapper entry points | GL.glGetProgramBinary/glProgramBinary/glProgramParameteri, in JOGLWrap, JOGLWrapBackup (GL2ES2), LWJGLWrap (GL41, which sizes from the buffer, so the wrapper slices it) and opt/panama’s OpenGL.Base (a lookup + MethodHandle each; heapdata where a buffer is passed) |
Gotcha — haven.render.gl.GL has FOUR implementations, and the build checks three. JOGLWrap,
JOGLWrapBackup and LWJGLWrap live under src/ and fail ant hafen-client the moment the interface gains a
method; the fourth, opt/panama/haven/ffi/gl/OpenGL.java (OpenGL.Base, the FFI backend behind every
Resolved in EGL, OSMesa, CGL, WGL and GLX), is compiled only by ant opt/panama — which ant
hafen-client never runs and ant release (so CI) does. A method added to GL is four wrappers, and the
check is rm -rf build/opt/panama && ant opt/panama; the first sign otherwise is a red publish run naming
EGL.Resolved is not abstract.
Gotcha — the attribute locations used to be a different set every run. GLProgram’s constructor sorts
the attributes to assign their locations, primary first, and the tie-break was Utils.idcmp — identity hash
order, different for the same program in every JVM. Nothing minded while the locations lived and died with the
process; a cached binary bakes the locations of the link that produced it and must match this run’s, so the
tie-break is now the attribute’s generated name (ctx.symtab), which the sources fix. Anything else that
keys on a program’s input across runs needs the same care: the sources are stable, the identity of the
objects behind them is not.
Gotcha — a program the driver refuses freezes the client, with no error on screen. A compile or link
failure throws ShaderException/LinkException from ShaderOb.create/ProgOb.create inside
GLEnvironment.process, wrapped by BufferBGL.run as BGLException. The frame’s renders are already off the
queue and are neither run nor aborted, so their fences never fire; JOGLPanel.redraw (and LWJGLPanel’s) has
no try, so the busy bit in pstate is never cleared and no display is scheduled again; and the UI thread’s
next Frame.syncwait waits on the lost fence with no timeout. The window shows its last frame and takes no
input; the stack trace goes to stderr only. Nothing on the UI thread sees it either — GLProgram.glid() only
queues the objects. The only protection is to ask the driver first: a BGL.Request submitted through a
render of its own (GLRender.submit(BGL.Request), the render from the window’s environment as the next gotcha
says) that compiles and links the two sources itself and reads GL_COMPILE_STATUS /
GL_LINK_STATUS instead of throwing. The budget that binds is the fragment stage’s uniform registers, not
the component count GL_MAX_FRAGMENT_UNIFORM_COMPONENTS reports: measured on a GTX 1660 SUPER (NVIDIA
591.86), a program with 2,977 uniform components took about 750 of its 1,024 vec4 registers, and one more
past 1,018 was refused at link (C6020: Constant register limit exceeded). GL 3.x only guarantees 1,024
components (256 vec4).
Gotcha — on GLX and Cocoa every window shares one GL environment, and a render must name its window.
GLXContext’s GLXEnvironment and CocoaContext’s CGLEnvironment are one context for all of a toolkit’s
windows; a window’s env() is that environment’s ProxyEnv, a GLProxy holding the drawable (or NSView).
GLEnvironment.process runs every GL call through process(gl, ctx, cmd), which those two override to make the
render’s window current, reading it off ((ProxyEnv)ctx.env()) — so a render has to come from the proxy:
GLProxy.render() hands out a ProxiedRender, whose env() is the proxy (hence GLRender.env() returns an
Environment). The shared environment’s own render() throws (raw render-buffers not available in shared
environments). Unwrapping an environment to reach GL stops at the first GLProxy; WGL, JOGL, NEWT and LWJGL hand
out their GLEnvironment itself.
| What | Where |
|---|---|
| The render | MapView.drawsmap → ShadowMap.update(out, slist): clears lbuf (a depth texture, 2048² at GSettings.shadowres 0), then ShadowList.draw — a DrawList of its own over the instancer’s slots, those carrying Light.lighting and no ShadowMap.maskshadow |
| Its placement | MapView.updsmap → setpos: the light camera (lcam, Camera.dir) 1000 units back along the light from the player, placed again once the player is 50 units from where it last was, or the light has turned (asked every 0.1 s); lproj is an ortho box of ±750, depth 1–5000 |
| The sampling | ShadowMap.Shader mods Phong’s per-light loop (Phong.dolight) for the one light sl names; shcalc is the lit fraction, txf takes the eye position into the map |
| Hardware PCF (fork) | lsamp compares: Texture.Sampler.compare, set by GLTexture.Tex2D.setsampler as GL_COMPARE_REF_TO_TEXTURE + GL_LEQUAL, LINEAR both ways. Its uniform is Type.SAMPLER2DSHADOW, which upstream declares and UniformApplier never mapped. shcalc makes (res/2)² fetches a texel off each way (4 for the 4×4 footprint the 16-sample loop read), each the hardware’s blend of four comparisons, against z − thr. The fetch is a float-typed Function.Builtin on Function.Builtin.texture.name: a second Symbol.Fix("texture") in the same program throws |
| Slope-scaled bias (fork) | ShadowList.shadowbasic carries States.DepthBias(3, 0) (glPolygonOffset), and Shadowslot.gstate answers the pass’s own depthbias over any a caster carries. The PCF compares against texel-centre depths up to two texels off, and the ground’s depth moves texel · cos²e / sin e per texel under a sun at elevation e (0.5 units at 45°, 3.4 at 12°) against a receiver bias thr of one unit: without the offset a low sun stripes the ground a texel apart |
| Casters culled to the box (fork) | ShadowList keeps every candidate (slots, order) and puts in back only those whose FastMesh.bounds() reach the box (Shadowslot.drawn). ShadowList.cull(zone), from update with zone = lproj × lcam, re-asks a few candidates per drawn map, round-robin, and moves a bounded number in or out, since each move compiles or drops a draw slot; a caster enters within ENTER of the box and leaves past LEAVE. An InstanceBatch, a slot with no Homo3D.loc and a non-FastMesh object are always drawn |
| One frame in two (fork) | MapView.drawsmap skips every other frame while ShadowMap.samezone(smapdrawn) holds — the same buffer seen from the same light camera — and draws at once when it does not. A skipped frame samples the map drawn a frame earlier from that same camera; only a moving caster’s shadow lags |
Gotcha — the shadow map covers a box, and upstream shaded everything outside it. The map is sampled with
Texture.Wrapping.CLAMP and cleared to 1.0, so a fragment outside the box read the clamped edge texels, and one
past the light’s far depth failed every comparison: distant ground came out in blocky black smears once anything
drew that far. Fork: shcalc returns fully lit when the coordinates leave [0,1] in x or y or the depth passes
1 (// addon:).
Gotcha — the box is neither centred on the player nor square to the light. Camera.makedir rotates about
defdir × dir without normalising it, and Transform.makerot takes its axis as unit length, so with the light
tilted the light camera is not a pure rotation: the player lands off the box’s centre, and a point moved along
dir moves in the map. Rendering and sampling share the matrix, so the shadows are right; a question of the form
“does this reach the shadow map” is asked through lproj × lcam, as ShadowList.cull does, never by distance
from the player.
Gotcha — a new sun means a new ShadowMap. updsmap hands the sun to ShadowMap.light, which compares by
identity, and upstream MapView.amblight() built a new DirLight every tick: a new ShadowMap state every frame
though nothing moved. Fork: the sun is kept while its values hold (world-effects.md), but it
is still made anew each tick while it turns, and a colour change turns out a new state that moves nothing. Anything
deciding whether the map changed compares what it holds (samezone), never the object.
g.image actually does)| What | Where |
|---|---|
| The interface | Tex: sz() + the one primitive render(GOut, float[] gc, float[] tc); everything else (render(g,dul,dbr,tul,tbr), crender) is a default that builds gc/tc and calls it. gc = 4 screen corners, tc = the same 4 in texel coordinates, both interleaved x,y, corner i at [2i], [2i+1] |
GOut.image → the primitive |
GOut.image(Tex,Coord[,Coord]) → tex.crender(...), which clips against g.ul/g.br by trimming the texel rectangle, then calls render |
| The concrete blit | TexRender.render divides tc by sz() and draws one 4-vertex TRIANGLE_STRIP of Ortho2D.pos+Tex2D.texc with usestate(draw) — an EPHEMERAL VertexArray through GLRender.draw |
| Drawing a texture at another size, without a second texture | ScaledTex<T> — a Tex holding an impl and a sz of its own, delegating every render to the impl and passing its own sz() as the destination rectangle. So g.image(new ScaledTex(t, s), c) covers s, resampled by the sampler, and dispose() disposes the impl — a view, not a resource, and disposing both double-frees. UI.scale(Tex) is the constructor everything uses; see ui-scaling.md |
| A render target blits UPSIDE DOWN unless flipped | tc is always counted from the image top, which is right for a TexI and wrong for a texture the client drew into (see world-3d.md’s v-flip gotcha). The engine’s own answer is a flag: TexRaw(Sampler2D, boolean invert), which Window.gbuf passes true. A hand-rolled TexRender over a render target must invert t itself (t → sz().y - t) before delegating |
| What | Where |
|---|---|
| The whole set | GLPipeState.all — viewport, scissor, facecull, depthtest, maskdepth, linewidth, depthbias. Each is a GLPipeState<T> over one State.Slot, and each apply(env, gl, from, to) is the only place its GL calls are written |
| Depth testing | slot States.depthtest, value a States.Depthtest(Test) (LT by default; FALSE TRUE EQ NEQ LT GT LE GE). GLPipeState.depthtest.apply → glEnable(GL_DEPTH_TEST) + glDepthFunc for a non-null, glDisable(GL_DEPTH_TEST) for a null |
| Turning the test off | States.Depthtest.none — a bare Pipe.Op, p -> p.put(depthtest, null), so it goes straight into a Material(Pipe.Op...) list beside anything else |
| Depth writing | States.maskdepth, a State.StandAlone(Slot.Type.GEOM) with no shader macro. GLPipeState.maskdepth.apply → glDepthMask(false) when present, glDepthMask(true) when absent |
| A material carrying render state | Material(Pipe.Op... states); MapView.gridmat is the in-code precedent — BaseColor, States.maskdepth, a MapMesh.OLOrder and a Location in one list, no .res anywhere |
Gotcha — maskdepth is not the depth test, and the two read as one word. maskdepth stops a fragment
writing depth; Depthtest.none stops it being tested against the depth already there. An overlay
carrying only maskdepth is still hidden by a wall, and adding a second maskdepth, or a
Depthtest(Test.TRUE) in place of the null, is not the same thing as either. Depthtest.none is also the
one of the pair that is a Pipe.Op rather than a State, so it has no apply(Pipe) to call and no
instance to compare — it is put in the list or it is not.
Gotcha — a null in a pipe slot is a state, not an absence. GLPipeState.apply is called with
to == null exactly to mean “the off setting”, which is why each of the seven above has a defined answer
for a null and why Depthtest.none works by putting one rather than by removing anything.
| What | Where |
|---|---|
| The template | BaseColor is the whole of it: a State with a static final State.Slot<>(Slot.Type.DRAW, …), apply(Pipe) = p.put(slot, this), a Uniform whose value function reads p.get(slot), and one static final ShaderMacro that mods FragColor.fragcol(prog.fctx) |
| Where the colour is composed | FragColor.fragcol(fctx).mod(fn, order) — the orders compose, and BaseColor mods at 0, so a mod at a higher order scales the colour it produced. FragColor.fragcol is a ValBlock.Value: it is emitted only if something force()s it, which the FragColor state’s own macro does |
| A loop in a shader | haven.render.sl carries Array, Index (Cons.idx), For and Block.local, but a mod is an expression transformer and cannot hold statements. The vehicle is a Function.Def whose code block is filled in an instance initialiser and which is call()ed from the mod; FragmentContext.construct → main.define walks the Call elements and emits the definition. Precedents: Outlines.msfac, Lighting.LightList.construct |
| A branch inside one | haven.render.sl.If(cond, Statement) (and the three-arm (cond, t, f)) takes a Block as either arm, so new Block() plus add is a multi-statement body. Precedents: Phong.DoLight.cons (new If(gt(dl.ref(), l(0.0)), dcalc = new Block())), PoseMorph.Shader.skan, Lighting.LightList.construct, Outlines.shader. ⚠️ fwidth/dFdx/dFdy after a loop are defined only while that loop’s control flow is UNIFORM across the quad — the bound and every condition inside it read uniforms rather than anything varying — so a fold branching on a Uniform array’s own contents keeps the derivative legal where one branching on the fragment’s own position does not. FragmentContext marks that boundary in the emitted main itself, as the Placeholder("Uniform control up until here.") its uniform ValBlock writes |
| A shader function that takes an argument | Function.param(PDir, Type) returns a Parameter whose ref() is the argument inside the body; Def.prototype writes in/out/inout from the PDir. Precedent: PoseMorph.Morpher.skan. Function.ckparams, from call(), checks the arity and demands an LValue for an OUT/INOUT argument — so a mod that must transform the fragment colour rather than merely read a value passes it in as one IN parameter, which is also how two results off one computation stay one call instead of two |
| A builtin the DSL has no name for | Function.Builtin’s constructor is public — new Function.Builtin(Type.FLOAT, new Symbol.Fix("fwidth"), 1) declares one in a line. Cons names about thirty; the screen-derivative family (fwidth, dFdx, dFdy) is not among them |
| An array uniform | new Uniform(new Array(Type.VEC4, n), "name", …, slot); PoseMorph.bo (Array(MAT4, nb)) is the precedent. UniformApplier.TypeMapping.register is public static, and UniformApplier.apply looks an array value up under the unsized new Array(el) while passing the sized type to the mapping — so one registration covers every length. ProgOb.uniresolve names the base UniformID after the bare array, i.e. element 0, so a single glUniform4fv(var, n, buf) uploads n of them |
| Naming, across programs | Symbol.Gen is unique per context and Symbol.Shared per program, so a static final Uniform or Function.Def shared by every program that uses the state is safe. Symbol.Fix throws on a name conflict, which is what makes a hand-declared builtin loud rather than silently shadowed |
Gotcha — a uniform is baked, not re-read. GLDrawList.DrawSlot.getsettings resolves every one of the
program’s uniforms when the slot is compiled, and getuniform caches the resulting GL call under a
SettingKey of (program, uniform, the Pipe group each dep came from) — compared by identity
throughout. So mutating a live State’s field afterwards propagates nothing at all: the new value reaches
the screen only as a new state instance pushed through the slot, which puts it in a new group and so
under a new key.
Gotcha — a batch is keyed on every non-instanced slot’s state, by identity. InstanceList.InstKey
holds the Instancable’s instanceid(), the pipe group behind every slot whose State.Slot.instanced is
null (uinststate, over stcounts), and the Instancer of every slot that has one; equals compares each
by ==. So a State whose slot has no Instancable (ColorMask, any new Slot built without
.instanced(...)) put on one member of a batch takes that member out of the batch and draws it in a
draw call of its own — correctly, and that draw call is the whole cost — while an instanced one
(MixColor, Location) rides along as a per-instance attribute. An Instancable buys the draw call
back at the price of an Instancer and an attribute path to keep right.
Gotcha — Slot.add(n, state) sets the child’s cstate. Replacing what an added put in is therefore
Slot.cstate, not Slot.ostate — ostate is the other one and layers a second op over the first, which
merely happens to work when both are put(slot, …) of the same slots. Both are no-ops when the op is ==
the one already there, so a freshly minted op every tick re-pushes forever (the Location warning on
world-3d.md is the same trap one level down).