shaders.kt
src/jvmMain/kotlin/afield/graphics/shader/shaders.kt · 5910 lines · cited by GPU State — keeping a simulation on the card
package and imports — 189 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Graphics")
package afield.graphics.shader
import afield.app.commands.action
import afield.data.collections.times
import afield.data.graph.net
import afield.data.graph.rules
import afield.geometry.mesh.bounds
import afield.geometry.mesh.expanded
import afield.geometry.mesh.gradient
import afield.geometry.mesh.translated
import afield.geometry.spatial.closest
import afield.graphics.interaction.Ray
import afield.graphics.text.layout
import afield.lang.stacksl.evolution.render
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.floor
import afield.math.autodiff.max
import afield.math.autodiff.mean
import afield.math.autodiff.min
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.round
import afield.math.autodiff.sign
import afield.math.autodiff.sin
import afield.math.autodiff.times
import afield.math.linalg.d
import afield.math.linalg.mat4
import afield.math.linalg.remAssign
import afield.math.linalg.times
import afield.math.linalg.xy
import afield.math.linalg.xyz
import afield.math.statistics.noise
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.media.image.close
import afield.media.image.open
import afield.media.video.image
import afield.simulation.agents.step
import afield.simulation.automata.slice
import afield.simulation.automata.step
import afield.study.ui.numbers
import afield.ai.learning.glsl
import afield.animation.keyframes.h
import afield.app.commands.Action
import afield.app.commands.actions
import afield.data.collections.get
import afield.data.collections.invoke
import afield.data.collections.isTrue
import afield.data.collections.m
import afield.data.collections.r
import afield.data.collections.s
import afield.data.graph.main
import afield.document.properties.Delegation
import afield.document.properties.Inheritance
import afield.document.workspace.path
import afield.geometry.mesh.contains
import afield.geometry.mesh.normalized
import afield.geometry.mesh.summary
import afield.graphics.color.rgba
import afield.graphics.interaction.pointer
import afield.graphics.render.label
import afield.graphics.vulkan.buffers.add
import afield.io.osc.main
import afield.lang.stacksl.StackAutoNumbers
import afield.lang.stacksl.h
import afield.math.autodiff.dot
import afield.math.autodiff.maxOf
import afield.math.control.AdaptiveDistributionController
import afield.math.control.ControlOutput
import afield.math.control.ControllerConfig
import afield.math.control.UniformQuantileFeatureExtractor
import afield.math.control.main
import afield.math.control.params
import afield.math.linalg.arr
import afield.math.linalg.f
import afield.math.linalg.i
import afield.math.linalg.invoke
import afield.math.linalg.l
import afield.math.linalg.rgba
import afield.math.linalg.set
import afield.math.linalg.x
import afield.math.statistics.FIELD_QUANTILE_WARP_INCLUDE_NAME
import afield.math.statistics.fieldQuantileWarpIncludeSource
import afield.math.statistics.main
import afield.media.image.channel
import afield.media.image.normalized
import afield.media.image.resize
import afield.media.image.split
import afield.runtime.execution.end
import afield.runtime.execution.start
import afield.runtime.lifecycle.Once
import afield.runtime.lifecycle.Onceing
import afield.runtime.lifecycle.cached
import afield.simulation.automata.main
import afield.simulation.particles.glsl
import afield.ui.output.ShaderTransitionNotes
import afield.ui.output.h
import afield.ai.agent.*
import afield.ai.conversation.*
import afield.ai.tools.*
import afield.animation.gesture.*
import afield.animation.interpolation.*
import afield.animation.keyframes.*
import afield.app.diagnostics.*
import afield.app.notifications.*
import afield.app.protocol.*
import afield.app.server.*
import afield.app.settings.*
import afield.audio.synth.*
import afield.data.table.*
import afield.document.context.*
import afield.document.graph.*
import afield.document.history.*
import afield.document.persistence.*
import afield.document.properties.*
import afield.document.workspace.*
import afield.graphics.color.*
import afield.graphics.paint.*
import afield.graphics.vulkan.*
import afield.math.functions.*
import afield.media.capture.*
import afield.media.stream.*
import afield.runtime.completion.*
import afield.runtime.execution.*
import afield.runtime.kotlin.*
import afield.runtime.lifecycle.*
import afield.tools.automation.*
import afield.tools.docs.*
import afield.ui.controls.*
import afield.ui.layout.*
import afield.ui.notation.*
import afield.ui.output.*
import afield.ui.output.templates.*
import afield.ui.slides.*
import afield.ui.visualization.*
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.device.Instance.Companion.globalAsyncDebugHandler
import afield.graphics.render.*
import afield.graphics.scene.*
import afield.graphics.shader.*
import afield.graphics.vulkan.targets.RenderToTexture
import afield.graphics.texture.*
import afield.graphics.vulkan.util.*
import afield.app.diagnostics.logging.lprintln
import afield.runtime.macros.Insert
import afield.runtime.macros.MacroRegistry
import afield.runtime.macros.stripPreambleSupplierTag
import afield.lang.stacksl.StackSLCompileOptions
import afield.lang.stacksl.StackSLCompileResult
import afield.lang.stacksl.StackSLCompiler
import afield.lang.stacksl.StackSLSeverity
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import afield.math.linalg.Vector3f
import afield.math.linalg.Vector4f
import org.lwjgl.glfw.GLFW
import org.lwjgl.system.MemoryUtil
import org.lwjgl.vulkan.VK10.VK_FORMAT_R8G8B8A8_SRGB
import java.net.HttpURLConnection
import java.net.URI
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.time.LocalDateTime
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import java.util.WeakHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import java.util.Locale
import afield.ui.output.md
import afield.ui.visualization.Table
import afield.ui.output.FieldMarkdown
The bridge between a box's text and a live Vulkan pipeline: shader source lives in box properties, and this file turns it into modules and keeps them hot-reloading.
The properties come first — vertex, fragment, compute, task/mesh, the ray-tracing
stages, and a _s variant of each for Slang — alongside once.vertex { }-style seeders. The
user-facing entry points are the macro placeholders Box.moduleHere, Box.computeModuleHere,
Box.meshModuleHere, Box.rayTracingModuleHere and Box.shadertoyHere; each one throws
MacroNotExpandedException if actually called, because MacroRegistry is expected to rewrite it
into the real _moduleHere-family call.
Before compilation every stage goes through preprocess, which resolves {% property %} template
tags against other boxes' shader properties (with cycle detection and #line fixups) and against
built-in includes such as the field probe, field stats and quantile warp. Registered include roots —
registerShaderFilesystemIncludeRoot for a directory, registerLygiaIncludeRoot /
shaderCachedUrlIncludeRootResolver for URL-fetched, disk-cached libraries — are not reachable
from here: they are resolved by shaderc during compilation, from a real #include only. It also expands
{% copyPC %} (copying a previous stage's push-constant block) and stackAuto template slots
compiled from StackSL via stackAutoShaderSource.
reloadShader, reloadComputeShader, reloadMeshShader and reloadRayTracingShader are the
reload path: compile, report errors back onto the box's source lines, introspect the result, and
rewrite the box's Kotlin preamble with generated binding structures before swapping the module.
Box.attachShader adopts an externally built module into that same path. Box.fieldProbeHere,
Box.fieldStatsHere and Box.fieldProbeAutoControl attach the GPU readback instrumentation.
Everything here needs a live Box — properties, error reporting, preamble edits and the
generation-guarded async reload all key off it.
Box properties
var Box.fragment by Inheritance<String?>()
The fragment-stage GLSL for this box, as text — the shader tab you type into, and the only
place a graphics module's fragment shader comes from (assignments inside a moduleHere { }
lambda do not set it).
Inherited, so a box with none of its own reads its nearest ancestor's and one parent can dress a
whole family of children. Writing it does not recompile anything by itself: reflected
bindings and the live pipeline only change when something explicitly reloads — the Reload
Shader action (alt-r), Update Shader (alt-shift-enter), Box.attachShader, or an AI
provisional accept — and those actions only exist on a box that has expanded a
moduleHere-family macro or called attachShader. Box.fragment_s and Box.vertex_fragment
both take precedence over this when they are non-blank.
var Box.vertex by Inheritance<String?>()
The vertex-stage GLSL for this box. Its layout(location = …) in declarations are the contract
that generates the box's Mesh writers (pos, col, …), so changing an input here changes the
Kotlin API of every box downstream of the next reload.
Same rules as Box.fragment: inherited, read at prepare time, and overridden by Box.vertex_s
or Box.vertex_fragment when either is non-blank. If a box has neither vertex nor fragment
source when moduleHere expands, both are seeded from defaultVertexShader /
defaultFragmentShader.
var Box.vertex_fragment by Inheritance<String?>()
One Slang source holding both the vertex and fragment stages, compiled twice (once per stage)
by slangc. It beats vertex, fragment, vertex_s and fragment_s outright: when this is
non-blank it is the source for both graphics stages.
Worth knowing: this is the one path whose reflection comes from Slang's own reflection JSON
rather than from shaderc's SPIR-V walk, so the generated bindings are produced by a different
code path than the GLSL boxes use. Needs slangc on the machine; a transpile failure is reported
on this property's lines and the stage is abandoned with the previous module left running.
var Box.task by Inheritance<String?>()
The task-shader (amplification) stage GLSL of a mesh-shader pipeline. meshModuleHere needs
task, mesh and fragment — there is no task-less mesh path here — and fills in whichever of
the three the box is missing, each independently and each with its own stub: an EmitMeshTasksEXT
amplifier here, a SetMeshOutputsEXT triangle emitter for mesh, and a constant-colour write for
fragment. A box that already has a task shader keeps it and gets only the other two.
var Box.mesh by Inheritance<String?>()
The mesh-shader stage GLSL, the stage that emits vertices and primitives directly
(SetMeshOutputsEXT / gl_MeshVerticesEXT) instead of consuming a vertex buffer. Paired with
Box.task and Box.fragment by meshModuleHere; note the name collides conceptually with
Mesh the Kotlin draw object, which is unrelated.
var Box.fragment_s by Inheritance<String?>()
Slang source for the fragment stage. When non-blank it wins over Box.fragment and is
transpiled to GLSL by slangc before shaderc ever sees it, so compile errors arrive in Slang's
(line): error format — which numberFromErrorMessage does understand.
var Box.vertex_s by Inheritance<String?>()
Slang source for the vertex stage; wins over Box.vertex when non-blank. Prefer
Box.vertex_fragment if you want the two stages to share declarations — two separate _s
properties are transpiled independently and cannot see each other.
var Box.task_s by Inheritance<String?>()
Slang source for the task stage; wins over Box.task when non-blank.
var Box.mesh_s by Inheritance<String?>()
Slang source for the mesh stage; wins over Box.mesh when non-blank.
var Box._fragment by Delegation<String?>()
The prepared fragment GLSL: box-local, post-#include-template, post-{% copyPC %},
post-Slang-transpile — literally the text handed to shaderc. Written by every prepare pass and
read by _moduleHere, which throws a NullPointerException if no pass ever ran.
Not inherited (unlike Box.fragment) and not worth hand-editing: the next prepare overwrites it.
It is, however, the right thing to read when you want to see what your {{ }} slots and
includes actually expanded to.
var Box._vertex by Delegation<String?>()
The prepared vertex GLSL — see Box._fragment for what "prepared" means and why you should
treat it as read-only.
var Box._task by Delegation<String?>()
The prepared task-stage GLSL. See Box._fragment.
var Box._mesh by Delegation<String?>()
The prepared mesh-stage GLSL. See Box._fragment.
var Box.compute by Inheritance<String?>()
The compute-stage GLSL for this box, the source computeModuleHere { } builds from. Its
layout(binding = …) buffer blocks and push constants generate the box's ComputeMesh
accessors.
Inherited, and seeded with a trivial outData[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240) = inData[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240) + 1 kernel if the box has none when
the macro expands. Box.compute_s wins over it when non-blank.
var Box.compute_s by Inheritance<String?>()
Slang source for the compute stage; wins over Box.compute when non-blank.
var Box._compute by Delegation<String?>()
The prepared compute GLSL. See Box._fragment.
var Box.raygen by Inheritance<String?>()
The ray-generation stage GLSL: the one ray-tracing stage that is required, and the only one
rayTracingModuleHere seeds a default for (defaultRaygenShader). The others are all optional
and are simply skipped if their property is blank.
var Box.miss by Inheritance<String?>()
The miss-shader stage GLSL, run when a traced ray hits nothing. Optional: leave it blank and the stage is left out of the pipeline entirely.
var Box.closesthit by Inheritance<String?>()
The closest-hit stage GLSL. Optional; omitted from the pipeline when blank.
var Box.anyhit by Inheritance<String?>()
The any-hit stage GLSL, run per candidate intersection (alpha-cutout style rejection). Optional; omitted from the pipeline when blank.
var Box.intersection by Inheritance<String?>()
The intersection stage GLSL for custom (non-triangle) primitives. Optional; omitted from the pipeline when blank.
var Box.callable by Inheritance<String?>()
The callable-shader stage GLSL. Optional; omitted from the pipeline when blank.
var Box.raygen_s by Inheritance<String?>()
Slang source for the raygen stage; wins over Box.raygen when non-blank.
var Box.miss_s by Inheritance<String?>()
Slang source for the miss stage; wins over Box.miss when non-blank.
var Box.closesthit_s by Inheritance<String?>()
Slang source for the closest-hit stage; wins over Box.closesthit when non-blank.
var Box.anyhit_s by Inheritance<String?>()
Slang source for the any-hit stage; wins over Box.anyhit when non-blank.
var Box.intersection_s by Inheritance<String?>()
Slang source for the intersection stage; wins over Box.intersection when non-blank.
var Box.callable_s by Inheritance<String?>()
Slang source for the callable stage; wins over Box.callable when non-blank.
var Box._raygen by Delegation<String?>()
The prepared raygen GLSL. See Box._fragment.
var Box._miss by Delegation<String?>()
The prepared miss GLSL, or null when the box has no miss stage. See Box._fragment.
var Box._closesthit by Delegation<String?>()
The prepared closest-hit GLSL, or null when the box has no such stage. See Box._fragment.
var Box._anyhit by Delegation<String?>()
The prepared any-hit GLSL, or null when the box has no such stage. See Box._fragment.
var Box._intersection by Delegation<String?>()
The prepared intersection GLSL, or null when the box has no such stage. See Box._fragment.
var Box._callable by Delegation<String?>()
The prepared callable GLSL, or null when the box has no such stage. See Box._fragment.
var Box._shader by Delegation<ModulePointer?>()
The live graphics module this box owns, or null if it has never built one. Set by
Box._moduleHere and Box.attachShader; a re-run replaces the pointer rather than adding a
second module.
Its presence matters outside this file: it is how the editor decides that this box has a graphics pipeline worth offering reflected GLSL completions for, and how the AI/provisional path decides a shader-property edit should trigger a reload. Box-local, and it has to be — a child box must build its own module rather than inherit its parent's.
var Box._computeShader by Delegation<ComputeModulePointer?>()
The live compute module this box owns, or null. Set by Box._computeModuleHere and
Box.attachShader; see Box._shader for the ownership rules.
var Box._rayTracingShader by Delegation<RayTracingModulePointer?>()
The live ray-tracing module this box owns, or null. Set by Box._rayTracingModuleHere and
Box.attachShader; see Box._shader.
var Box.meshShader by Inheritance<MeshModulePointer?>()
Vestigial: declared, inherited, and read or written by nothing in the tree. The mesh pipeline
uses the box-local Box._meshShader throughout; treat this as a name to avoid rather than an
API.
var Box._meshShader by Delegation<MeshModulePointer?>()
The live mesh-shader module this box owns, or null. Set by Box._meshModuleHere and
Box.attachShader; see Box._shader.
var Box._fieldProbe by Delegation<ShaderFieldProbe?>()
This box's GPU sample probe, or null if nothing has asked for one. Created on demand either by
Box.fieldProbeHere or by the first #include "fieldProbe" in one of this box's shaders, so the
include alone is enough to bring it into being.
The SSBO that backs it is wired into a module's parameters at module construction or reload, so a probe that appears after the module already exists stays unbound until the next reload.
var Box._fieldProbeAutoController by Delegation<ShaderFieldAutoController?>()
The per-frame controller installed by Box.fieldProbeAutoControl, or null. Kept on the box so
a re-run can stop the previous one before installing its replacement — which is exactly what
fieldProbeAutoControl does, and why re-running a box does not leave two controllers fighting
over the same push constants.
var Box._fieldStatsBinding by Delegation<ShaderFieldStatsBinding?>()
How this box is joined to a ShaderFieldStats block: the stats object plus whether this box is its producer (it owns the buffers and its GLSL records into them) or merely a consumer.
#include "fieldStats" reads this to decide which GLSL to emit, and throws if it is null —
so a box that includes fieldStats must first call Box.fieldStatsHere (producer) or
Box.useFieldStats (consumer).
once.vertex { ... } / once.fragment { ... } etc. seed a box's shader-source property once,
mirroring once.renderer { window = Window(...); Renderer(window) }. moduleHere { } builds its
module from the box's vertex/fragment properties (not from assignments in the lambda — those
are overwritten by the box's prepared _vertex/_fragment), so this is how box code hands a
module a non-default shader. Set these in a fragment before the moduleHere { } fragment (split
with //%-) so the box's later moduleHere expansion prepares its shaders from what you supplied.
val Once.vertex by Onceing<String>()
once.vertex { …GLSL… } fills the box's Box.vertex property from code, the first time only.
The trap is timing, not syntax: once.vertex runs at runtime and moduleHere reads the
property at macro-expansion time, so both in one fragment means moduleHere expands first,
finds nothing, and seeds defaultVertexShader instead. Put a //%- fragment break between them.
val Once.fragment by Onceing<String>()
once.fragment { …GLSL… } fills Box.fragment once from code. Same fragment-ordering trap as
Once.vertex: seed it in a fragment before the one that expands moduleHere.
val Once.vertex_fragment by Onceing<String>()
once.vertex_fragment { …Slang… } fills Box.vertex_fragment once — the shared-Slang route,
which overrides whatever once.vertex/once.fragment put in place.
val Once.compute by Onceing<String>()
once.compute { …GLSL… } fills Box.compute once, ahead of the computeModuleHere fragment.
val Once.task by Onceing<String>()
once.task { …GLSL… } fills Box.task once, ahead of the meshModuleHere fragment.
val Once.mesh by Onceing<String>()
once.mesh { …GLSL… } fills Box.mesh once, ahead of the meshModuleHere fragment.
val Once.raygen by Onceing<String>()
once.raygen { …GLSL… } fills Box.raygen once, ahead of the rayTracingModuleHere fragment.
val Once.miss by Onceing<String>()
once.miss { …GLSL… } fills Box.miss once; leave it unset to build a pipeline with no miss
stage at all.
val Once.closesthit by Onceing<String>()
once.closesthit { …GLSL… } fills Box.closesthit once. Optional stage.
val Once.anyhit by Onceing<String>()
once.anyhit { …GLSL… } fills Box.anyhit once. Optional stage.
val Once.intersection by Onceing<String>()
once.intersection { …GLSL… } fills Box.intersection once. Optional stage.
val Once.callable by Onceing<String>()
once.callable { …GLSL… } fills Box.callable once. Optional stage.
val Box.shaderTemplate by Delegation<MutableMap<String, Any>>
The named values this box's {{ slot }} template expressions resolve against — the mechanism
behind the shadertoy preamble and any GLSL boilerplate you would rather write once.
Resolution walks up the box graph (a child's slots shadow its parents') and then falls back to the
process-global template registry, so a slot can be supplied by an ancestor box or by
registerGlobalShaderTemplate. Values may be Strings, nested Maps (addressed as
{{ a.b.c }}) or Resolvers. An unresolved slot is a hard preprocessing error naming the line,
not a silent empty string.
Concurrent, like Box.stackAutoShaderSources. It used to default to a plain HashMap while this very
KDoc noted that "the shader update path reads it from a background thread, so mutating it during a
live update is not safe" — a known hazard, left standing. It is also gathered
(four/util/templating.kt), and gather merges every ancestor's copy with putAll, i.e. iterates
it — so this was a ConcurrentModificationException risk and not merely a torn-read one. See
PropertyBag.kt and dev_docs/box_shared_state_races.md for the rest of that family.
val Box.shaderTemplate by Delegation<MutableMap<String, Any>>(
defaultValue = { java.util.concurrent.ConcurrentHashMap<String, Any>() })
val Box.stackAutoShaderSources by Delegation<MutableMap<String, String>>
StackSL source, by stack.auto slot name, for every {{ stack.auto.foo }} this box's shaders
declare. Concurrent, because the compile/reload path reads it off the main thread.
You normally reach it through the generated module.foo = "p length ]2s2.0" properties rather
than directly; see stackAutoShaderSource. A name present with an empty value is normal — that
is a slot which has been discovered but never bound, and it compiles to the stable
vec4 foo(vec4 foo_in) { return vec4(0.0); } fallback.
val Box.stackAutoShaderSources by Delegation<MutableMap<String, String>>(
defaultValue = { ConcurrentHashMap<String, String>() })
private val stackAutoShaderNameRegex = Regex("[A-Za-z_][A-Za-z0-9_]*")
private val stackAutoEntrypointNameRegex = Regex("[A-Za-z_][A-Za-z0-9_-]*")
private val stackAutoModuleOwners = Collections.synchronizedMap(WeakHashMap<Any, Box>())
private data class StackAutoShaderTarget(
val name: String,
val entrypoint: String? = null,
val sampleTexture: String = "source"
) {
val key: String
get() = buildString {
append(name)
entrypoint?.let { append(":").append(it) }
if (sampleTexture != "source") append("@").append(sampleTexture)
}
}
private fun validateStackAutoShaderName(name: String) {
require(stackAutoShaderNameRegex.matches(name)) {
"stack.auto name '$name' must be a GLSL identifier."
}
}
The slot's GLSL function is called by its name, and on macOS the shader goes through Metal, where a
name such as decay or array is already taken. For those names a #define renames the function
and every later call to it before SPIR-V is made, so the user's spelling still works and Metal never
sees the clash.
private fun withMetalSafeName(name: String, glsl: String): String =
if (name in afield.graphics.shader.MetalShaderErrors.reservedNames) "\n#define $name stackauto_$name\n$glsl" else glsl
private fun validateStackAutoEntrypointName(name: String) {
require(stackAutoEntrypointNameRegex.matches(name)) {
"stack.auto entrypoint '$name' must be a StackSL definition name."
}
}
private fun validateStackAutoSamplerName(name: String) {
require(stackAutoShaderNameRegex.matches(name)) {
"stack.auto sampler '$name' must be a GLSL identifier."
}
}
private fun parseStackAutoShaderTarget(raw: String): StackAutoShaderTarget {
val trimmed = raw.trim()
val samplerMarker = trimmed.indexOf('@')
val body = if (samplerMarker >= 0) trimmed.substring(0, samplerMarker).trim() else trimmed
val sampleTexture = if (samplerMarker >= 0) trimmed.substring(samplerMarker + 1).trim() else "source"
val name = body.substringBefore(':').trim()
val entrypoint = body.substringAfter(':', missingDelimiterValue = "").trim().takeIf { it.isNotEmpty() }
validateStackAutoShaderName(name)
entrypoint?.let(::validateStackAutoEntrypointName)
validateStackAutoSamplerName(sampleTexture)
return StackAutoShaderTarget(name, entrypoint, sampleTexture)
}
data class StackAutoReport
What compiling one stack.auto slot had to say beyond "it compiled" — the warnings, and the stack
arithmetic that says whether the program is shaped the way its author thinks.
Why this exists
StackSLCompileResult.ok is errors only, and a stack language's characteristic mistake is not an
error. A definition must consume its input and leave its result — net zero. Open one with =name,
which copies the top (a -- a), where >name moves it (a --), and the definition leaves
its input sitting under its result. Every call then pushes two values instead of one, and everything
downstream silently takes the wrong operand. The compiler emits no diagnostic at all: it prunes the
surplus and carries on.
Observed cost of that going unreported, on 2026-08-28: a three-channel noise force in which one
channel read a raw coordinate off the stack rather than a noise sample — a constant of about +6.8
on one axis — and the first of three calls pruned away entirely. It compiled, the shader compiled,
ok was true, and it was found by a person noticing that turning the gain up translated the field
instead of agitating it. The numbers that would have said so immediately were sitting in
analysis.functions[].finalStackDepth: 2 for the definition and 6 for its root, with
prunedValues = 15.
What it does and does not claim
problems is high confidence: a definition that does not end at depth 1 is wrong, near enough
always. warnings is whatever the compiler said at less than ERROR severity, verbatim — those were
previously discarded on the success path. notes is softer: pruned values are legitimate (a marked
value that is never read is pruned by design), so a count is worth showing and not worth failing on.
A root program is deliberately not checked for depth. Its stack starts with the program's input
already on it, so "input x 12 *" — which pushes a second copy — legitimately ends at 2, and a
program that also leaves a marked value behind ends deeper still.
Reporting only. A slot with problems still binds: a program is often mid-edit, and refusing the bind
would be a worse tool than saying so. See stackAutoShaderReport to ask directly, and
Box.stackAutoReportChannel for where the live-coding path puts it.
data class StackAutoReport(
/** The slot this is about — the `foo` of `{{ stack.auto.foo }}`. */
val name: String,
/** False when the program has an ERROR diagnostic, which is then in [errors]. */
val compiled: Boolean,
/** Almost certainly bugs. A definition whose net stack effect is not "consume one, leave one". */
val problems: List<String> = emptyList(),
/** The compiler's own non-ERROR diagnostics: warnings and notes from a compile that succeeded. */
val warnings: List<String> = emptyList(),
/** Worth seeing, not worth failing on — chiefly how much computed work nothing consumes. */
val notes: List<String> = emptyList(),
/**
* Why it did not compile: the compiler's ERROR diagnostics, empty when [compiled]. [warnings]
* leaves errors out, so a report that says why a candidate was refused has to read this list.
*/
val errors: List<String> = emptyList()
) : ToHTML {
val quiet: Boolean get()
Nothing to say: it compiled, no warnings, every definition balanced, nothing pruned.
val quiet: Boolean get() = compiled && problems.isEmpty() && warnings.isEmpty() && notes.isEmpty()
val lines: List<String>
Every line, most serious first, for a log or a one-shot println.
val lines: List<String>
get() = errors.map { "error: $it" } + problems.map { "problem: $it" } + warnings + notes.map { "note: $it" }
override fun toHTML(): String
The report as a small monospaced card: one coloured line per problem, warning and note, and a
dim "clean" when there is nothing to say. This is what the stack.auto output channel shows.
override fun toHTML(): String = buildString {
append("<div style='font-family:ui-monospace,monospace;font-size:11px;line-height:1.45'>")
append("<div style='opacity:.65;margin-bottom:4px'>stack.auto.")
append(escapeHtmlText(name))
append(if (compiled) "</div>" else " — did not compile</div>")
for ((label, colour, items) in listOf(
Triple("error", "#e0553a", errors),
Triple("problem", "#e0553a", problems),
Triple("warning", "#d9a13a", warnings),
Triple("note", "#7f8c9b", notes)
)) {
for (item in items) {
append("<div><span style='color:")
append(colour)
append("'>")
append(label)
append("</span> ")
append(escapeHtmlText(item))
append("</div>")
}
}
if (quiet) append("<div style='opacity:.5'>clean</div>")
append("</div>")
}
override fun toString(): String
One line, for a log or a println. "stack.auto.<name>: clean" when there is nothing to say.
override fun toString(): String =
if (quiet) "stack.auto.$name: clean" else "stack.auto.$name: " + lines.joinToString("; ")
}
private fun escapeHtmlText(text: String): String =
text.replace("&", "&").replace("<", "<").replace(">", ">")
const val STACK_AUTO_REPORT_CHANNEL = "stack.auto"
The box output channel the live-coding path writes a non-StackAutoReport.quiet report to.
Opened when there is something to say and closed again when a later bind comes back clean, so it is silent while the program is fine and in front of you the moment it is not.
fun stackAutoShaderReport
Compile one StackSL program the way a stack.auto slot compiles it, and report what that turned up
beyond success or failure.
Pure: no device, no box, nothing bound or reloaded. The same call the live-coding setter makes, so a
box can ask before it binds — println(stackAutoShaderReport(program, "noiseForce")).
@param name the slot name, used as the generated GLSL prefix
@param entrypoint a StackSL definition to use as the root, as {{ stack.auto.foo:word }} would
@param sampleTexture the sampler sample/gather words read, as @name would set it
fun stackAutoShaderReport(
source: String,
name: String = "program",
entrypoint: String? = null,
sampleTexture: String = "source"
): StackAutoReport {
validateStackAutoShaderName(name)
if (source.isBlank()) return StackAutoReport(name, compiled = true)
val result = StackSLCompiler.compile(
source,
StackSLCompileOptions(
prefix = name,
entrypoint = entrypoint ?: "main",
useEntrypointAsRoot = entrypoint != null,
tolerant = false,
includeHeader = false,
declareSamplers = false,
externalizeConstants = false,
sampleTextures = listOf(sampleTexture)
)
)
return stackAutoReportFor(name, result)
}
internal fun stackAutoReportFor(name: String, result: StackSLCompileResult): StackAutoReport
stackAutoShaderReport's analysis half, for a result that has already been compiled.
body — 52 lines, from line 726
internal fun stackAutoReportFor(name: String, result: StackSLCompileResult): StackAutoReport {
val problems = mutableListOf<String>()
val notes = mutableListOf<String>()
// Only definitions that something actually *calls* are checked. Two shapes are legitimately
// deeper than 1 and neither is a defect:
//
// - the **root**, whose stack starts with the program's input already on it, so `"input x 12 *"`
// ends at 2;
// - a definition that is only ever used as an *entrypoint* (`{{ stack.auto.foo:tap }}`), because
// as a root it gets root semantics. `: tap 0.2 sample ;` is exactly that, and flagging it was
// this check's first false positive — caught by an existing fixture in StackSLSmokeTest.
//
// A definition nothing invokes cannot take the wrong operand from anything, so its shape is not a
// claim about behaviour. `calls` gives reachability for free.
val called = result.analysis.functions.flatMapTo(mutableSetOf()) { it.calls }
var prunedTotal = 0
for (f in result.analysis.functions) {
prunedTotal += f.prunedValues
if (f.name == "<root>" || f.name !in called) continue
if (f.finalStackDepth != 1) {
problems += "definition `${f.name}` ends with ${f.finalStackDepth} values on the stack, not 1 " +
"— it must consume its input and leave its result. `=mark` copies (`a -- a`), " +
"`>mark` moves (`a --`); everything after an unbalanced definition takes the wrong operand."
}
}
if (prunedTotal > 0) {
notes += "$prunedTotal computed value(s) pruned as unused — normal for a mark that is never " +
"read, suspicious otherwise"
}
return StackAutoReport(
name = name,
compiled = result.ok,
problems = problems,
warnings = result.diagnostics
.filter { it.severity != StackSLSeverity.ERROR }
.map { it.toString() },
notes = notes,
errors = if (result.ok) emptyList() else result.diagnostics
.filter { it.severity == StackSLSeverity.ERROR }
.map { it.toString() }
.ifEmpty { listOf("the StackSL compiler refused it without a diagnostic") }
)
}
private fun defaultStackAutoShaderFunction(name: String): String =
"vec4 $name(vec4 ${name}_in) { return vec4(0.0); }"
private fun compileStackAutoShaderFunction(target: StackAutoShaderTarget, source: String): String =
compileStackAutoShaderFunctionReporting(target, source).first
compileStackAutoShaderFunction plus the StackAutoReport the compile produced.
One compile, two consumers: the GLSL for the pipeline and the report for whoever is watching. The
report is null only for a blank slot, which compiles to the stable no-op fallback and has nothing
to say about it.
private fun compileStackAutoShaderFunctionReporting(
target: StackAutoShaderTarget,
source: String
): Pair<String, StackAutoReport?> {
val name = target.name
validateStackAutoShaderName(name)
target.entrypoint?.let(::validateStackAutoEntrypointName)
if (source.isBlank()) return defaultStackAutoShaderFunction(name) to null
val result = StackSLCompiler.compile(
source,
StackSLCompileOptions(
prefix = name,
entrypoint = target.entrypoint ?: "main",
useEntrypointAsRoot = target.entrypoint != null,
tolerant = false,
includeHeader = false,
declareSamplers = false,
externalizeConstants = false,
sampleTextures = listOf(target.sampleTexture)
)
)
if (result.ok) return result.glsl to stackAutoReportFor(name, result)
val diagnostics = result.diagnostics
.filter { it.severity == StackSLSeverity.ERROR }
.ifEmpty { result.diagnostics }
.joinToString("\n") { it.toString() }
throw IllegalArgumentException("Unable to compile stack.auto.${target.key}:\n$diagnostics")
}
data class StackAutoReportSet
Every slot of one box that currently has something to say, rendered as one output card.
A shader usually declares several stack.auto slots and a reload re-expands all of them, so reports
arrive one per slot in whatever order the template does. The state has to be per slot and the
output per box: publishing each report on its own would let a clean slot close the card a dirty
one had just opened, which is exactly what the first version did — open immediately followed by
close, and a real problem invisible.
data class StackAutoReportSet(
/** Every slot with something to say, ordered by slot name so the card does not reshuffle. */
val reports: List<StackAutoReport>
) : ToHTML {
override fun toHTML(): String = reports.joinToString("") { it.toHTML() }
Each slot's card, stacked, so one output area covers the whole shader.
override fun toString(): String = reports.joinToString("\n") { it.toString() }
One line per slot.
Per box, per slot, whatever that slot last had to say. Transient — never persisted with the box.
private val stackAutoReports =
Collections.synchronizedMap(WeakHashMap<Box, MutableMap<String, StackAutoReport>>())
Put a StackAutoReport where the person editing the program will see it.
A slot with nothing to say is removed rather than written as "clean", and the card is closed only once every slot is clean — so the output area is silent while the programs are fine and appears the moment one is not, without slots overwriting each other. Anything worth saying also goes to the log, because a headless run has no output area to look at.
private fun publishStackAutoReport(box: Box, report: StackAutoReport?) {
if (report == null) return
val perSlot = synchronized(stackAutoReports) {
stackAutoReports.getOrPut(box) { java.util.concurrent.ConcurrentHashMap() }
}
val previous = if (report.quiet) perSlot.remove(report.name) else perSlot.put(report.name, report)
// Log only on a change, or a shader reload would repeat itself every frame it happens on.
if (!report.quiet && previous != report) {
val severity = if (report.problems.isNotEmpty()) "warning,graphics" else "graphics"
report.lines.forEach { lprintln(severity, "stack.auto.${report.name}: $it") }
}
val live = perSlot.values.sortedBy { it.name }
if (live.isEmpty()) {
box.displayClose(STACK_AUTO_REPORT_CHANNEL)
return
}
box.display(STACK_AUTO_REPORT_CHANNEL, StackAutoReportSet(live))
box.displayOpen(STACK_AUTO_REPORT_CHANNEL)
}
private fun stackAutoShaderFunctionFor(box: Box, target: StackAutoShaderTarget, kind: ShaderKind? = null): String {
validateStackAutoShaderName(target.name)
box.stackAutoShaderSources.putIfAbsent(target.name, "")
if (kind != null && kind in STACK_AUTO_LIVE_STAGES && stackAutoLiveNumbersEnabled())
return withMetalSafeName(target.name, liveStackAutoShaderFunctionFor(box, target))
val (glsl, report) = compileStackAutoShaderFunctionReporting(
target, box.stackAutoShaderSources[target.name] ?: ""
)
// Also here, not only on the live-coding setter: a slot bound before its shader existed is compiled
// for the first time during preprocessing, and that is the compile whose warnings matter most.
publishStackAutoReport(box, report)
return withMetalSafeName(target.name, glsl)
}
The stages whose modules reflect uniform blocks into live structures, so a slot's numbers can live in one (StackAutoNumbers).
private val STACK_AUTO_LIVE_STAGES = setOf(
ShaderKind.VERTEX_SHADER, ShaderKind.FRAGMENT_SHADER, ShaderKind.COMPUTE_SHADER, ShaderKind.TASK_SHADER, ShaderKind.MESH_SHADER
)
Live numbers are on unless AFIELD_STACK_AUTO_LIVE_NUMBERS (or afield.stackAuto.liveNumbers) is 0 or false.
private fun stackAutoLiveNumbersEnabled(): Boolean {
val flag = System.getenv("AFIELD_STACK_AUTO_LIVE_NUMBERS") ?: System.getProperty("afield.stackAuto.liveNumbers")
return !(flag.equals("false", ignoreCase = true) || flag == "0")
}
Every shader text of box a slot's binding is chosen against, so all its stages agree on it.
private fun stackAutoShaderTexts(box: Box): List<String> =
listOf("vertex", "fragment", "compute", "task", "mesh").mapNotNull { runCatching { shaderPropertyValue(box, it) }.getOrNull() }
private val stackAutoRebuildHookInstalled = java.util.concurrent.atomic.AtomicBoolean(false)
A slot expanded with its numbers in a uniform block of its own (StackAutoNumbers): the block, then the function reading it. The block is emitted even for a blank slot, or one with too many numbers to hold, so that binding a program later keeps every uniform block as it was and the running pipeline can be updated in place.
source — 48 lines, from line 910
private fun liveStackAutoShaderFunctionFor(box: Box, target: StackAutoShaderTarget): String {
if (stackAutoRebuildHookInstalled.compareAndSet(false, true)) {
afield.graphics.render.ModuleRebuild.hooks += { rebuild -> StackAutoNumbers.carry(stackAutoShaderOwner(rebuild.pointer), rebuild) }
afield.graphics.render.ModuleRebuild.onCreated += StackAutoNumbers::created
// Code or a keyframe target that takes hold of a numbers block (`module.foo_numbers`) makes the slot live.
afield.graphics.render.ModuleRebuild.accessed += { pointer, blockName, structure ->
if (StackAutoNumbers.isNumbersBlock(blockName, structure.members.map { it.name }))
stackAutoShaderOwner(pointer)?.let { StackAutoNumbers.live(it, blockName.removeSuffix("_numbers")) }
}
StackAutoNumbers.moduleStructures = { b ->
synchronized(stackAutoModuleOwners) { stackAutoModuleOwners.entries.filter { it.value === b }.map { it.key } }.mapNotNull {
when (it) {
is ModulePointer -> it.current.liveUniformStructures
is ComputeModulePointer -> it.current.liveUniformStructures
is MeshModulePointer -> it.current.liveUniformStructures
else -> null
}
}
}
}
val name = target.name
val source = box.stackAutoShaderSources[name] ?: ""
val binding = StackAutoNumbers.binding(name, stackAutoShaderTexts(box))
val block = StackAutoNumbers.block(name, binding)
if (source.isBlank()) {
StackAutoNumbers.baked(box, name, source)
return block + defaultStackAutoShaderFunction(name)
}
val live = StackAutoNumbers.compileLive(name, source, target.sampleTexture, target.entrypoint)
if (live == null) {
// More numbers than the block holds: compiled as it always was, numbers baked in.
val (glsl, report) = compileStackAutoShaderFunctionReporting(target, source)
StackAutoNumbers.baked(box, name, source)
publishStackAutoReport(box, report?.let { it.copy(notes = it.notes + "more than ${StackAutoNumbers.CAPACITY} numbers, so they are compiled in rather than live") })
return block + glsl
}
val (glsl, result) = live
if (!result.ok) {
val diagnostics = result.diagnostics.filter { it.severity == StackSLSeverity.ERROR }.ifEmpty { result.diagnostics }.joinToString("\n") { it.toString() }
throw IllegalArgumentException("Unable to compile stack.auto.${target.key}:\n$diagnostics")
}
StackAutoNumbers.compiled(box, name, source, target.entrypoint, result.analysis.danglingWires)
publishStackAutoReport(box, stackAutoReportFor(name, result))
return block + glsl
}
private val stackAutoSlotLine = Regex("""^[ \t]*\{\{\s*stack\.auto\.[^}]*\}\}[ \t]*$""")
private val pushConstantBlockEnd = Regex("""layout\s*\(\s*push_constant[^)]*\)\s*uniform\s+\w+\s*\{[^}]*\}[^;]*;""")
internal fun stackAutoSlotsAfterPushConstants(source: String): String
source with any {{ stack.auto.x }} slot line that sits above the push-constant block moved to
just below it. The slot's generated function may read the block (?pc.knobs), so it has to come
after it; written above, glslang used to report 'pc' : undeclared identifier inside generated code.
#line keeps the lines after the moved slot numbered as written.
body — 59 lines, from line 965
internal fun stackAutoSlotsAfterPushConstants(source: String): String {
val block = pushConstantBlockEnd.find(source) ?: return source
val lines = source.split("\n")
val blockStartLine = source.substring(0, block.range.first).count { it == '\n' }
val blockEndLine = source.substring(0, block.range.last).count { it == '\n' }
val moved = (0 until blockStartLine).filter { stackAutoSlotLine.matches(lines[it]) }
if (moved.isEmpty()) return source
val out = ArrayList<String>(lines.size + moved.size * 2)
lines.forEachIndexed { index, line ->
out += if (index in moved) "" else line
if (index == blockEndLine) {
moved.forEach { out += lines[it].trim() }
out += "#line ${index + 2}"
}
}
return out.joinToString("\n")
}
private fun stackAutoTemplateResolverFor(box: Box, emitted: MutableMap<String, StackAutoShaderTarget>, kind: ShaderKind? = null): Resolver =
Resolver { key ->
fun resolveTarget(raw: String): String {
val target = parseStackAutoShaderTarget(raw)
val previous = emitted[target.name]
if (previous != null) {
require(previous == target) {
"stack.auto.${target.name} was already emitted as stack.auto.${previous.key}; cannot also emit stack.auto.${target.key} in the same shader stage."
}
return ""
}
emitted[target.name] = target
return stackAutoShaderFunctionFor(box, target, kind)
}
when {
key == "stack" -> Resolver { stackKey ->
when {
stackKey == "auto" -> Resolver { name -> resolveTarget(name) }
stackKey.startsWith("auto.") -> resolveTarget(stackKey.removePrefix("auto."))
else -> null
}
}
key == "stack.auto" -> Resolver { name -> resolveTarget(name) }
key.startsWith("stack.auto.") -> resolveTarget(key.removePrefix("stack.auto."))
else -> null
}
}
private fun stackAutoShaderOwner(module: Any): Box? =
synchronized(stackAutoModuleOwners) { stackAutoModuleOwners[module] }
private fun registerStackAutoShaderOwner(box: Box, module: Any) {
synchronized(stackAutoModuleOwners) {
stackAutoModuleOwners[module] = box
}
}
private fun stackAutoShaderBoxFor(module: Any): Box? =
stackAutoShaderOwner(module) ?: Box.context.get()
internal fun stackAutoModulesOf(box: Box): List<Any>
The modules box owns — graphics, compute and mesh pointers registered as their stack.auto slots' owner.
internal fun stackAutoModulesOf(box: Box): List<Any> =
synchronized(stackAutoModuleOwners) { stackAutoModuleOwners.entries.filter { it.value === box }.map { it.key } }
internal fun stackAutoOwnerOf(module: Any): Box? = stackAutoShaderBoxFor(module)
The box whose stack.auto slots module runs, or the ambient box — as the generated module.foo resolves it.
fun stackAutoShaderSource(module: ModulePointer, name: String): String
The StackSL source currently bound to the {{ stack.auto.name }} slot of this graphics module's
shaders — the getter behind the generated module.name property.
The module is only a handle to find the owning box by (registered when the module was built, and
falling back to the ambient Box.context); the source itself lives on the box. An unbound or
unknown slot reads back as "", never null, so you cannot tell "never set" from "set to
empty". A name that is not a GLSL identifier throws.
fun stackAutoShaderSource(module: ModulePointer, name: String): String {
validateStackAutoShaderName(name)
val box = stackAutoShaderBoxFor(module)
return box?.stackAutoShaderSources?.get(name) ?: ""
}
fun stackAutoShaderSource(module: ComputeModulePointer, name: String): String
The StackSL source bound to a compute module's stack.auto slot; "" when unbound. See the
ModulePointer overload for the ownership and empty-string rules.
fun stackAutoShaderSource(module: ComputeModulePointer, name: String): String {
validateStackAutoShaderName(name)
val box = stackAutoShaderBoxFor(module)
return box?.stackAutoShaderSources?.get(name) ?: ""
}
fun stackAutoShaderSource(module: MeshModulePointer, name: String): String
The StackSL source bound to a mesh module's stack.auto slot; "" when unbound. See the
ModulePointer overload for the ownership and empty-string rules.
fun stackAutoShaderSource(module: MeshModulePointer, name: String): String {
validateStackAutoShaderName(name)
val box = stackAutoShaderBoxFor(module)
return box?.stackAutoShaderSources?.get(name) ?: ""
}
private fun setStackAutoShaderSource(module: Any, name: String, source: String, reloadType: ShaderReloadType) {
validateStackAutoShaderName(name)
val box = stackAutoShaderBoxFor(module)
?: throw IllegalStateException("Cannot set stack.auto.$name without a module owner or active Box context.")
val currentSource = box.stackAutoShaderSources[name] ?: ""
if (currentSource == source) {
return
}
val compiled = try {
compileStackAutoShaderFunctionReporting(StackAutoShaderTarget(name), source)
} catch (t: Throwable) {
reportShaderStageError(
box,
"code",
"stack.auto.$name",
t.message ?: "Unable to compile stack.auto.$name",
t,
lineOverride = stackAutoErrorLine(box, source, t.message)
)
return
}
box.stackAutoShaderSources[name] = source
requestStackAutoShaderUpdate(box, reloadType)
// The compile succeeded, which is not the same as the program being right. See StackAutoReport.
publishStackAutoReport(box, compiled.second)
lprintln(
"graphics",
"compiled stack.auto.$name (${compiled.first.length} chars GLSL); queued shader update." +
(compiled.second?.takeIf { !it.quiet }?.let { " ${it.problems.size} problem(s), ${it.warnings.size} warning(s)." } ?: "")
)
}
fun stackAutoShaderSource(module: ModulePointer, name: String, source: String)
Bind new StackSL source to a graphics module's {{ stack.auto.name }} slot and queue the pipeline
update — the setter behind the generated module.name = "…" property, and the fastest live-coding
loop in the file.
The StackSL is compiled before anything is stored. On a compile error nothing changes: the
previous binding and the running pipeline are both left alone and the diagnostic is reported onto
the box's code property rather than thrown, so a half-typed expression does not take the frame
down. On success the update runs on a background coroutine through the ABI-preserving update path
(no full module rebuild), and rapid writes are coalesced into one pending follow-up. Writing the
value it already has is a no-op.
Throws only for a name that is not a GLSL identifier, or when the module has no registered owner
box and there is no ambient Box.context to fall back on.
fun stackAutoShaderSource(module: ModulePointer, name: String, source: String) {
setStackAutoShaderSource(module, name, source, ShaderReloadType.GRAPHICS)
}
fun stackAutoShaderSource(module: ComputeModulePointer, name: String, source: String)
Bind StackSL source to a compute module's stack.auto slot; queues the same coalesced,
ABI-preserving background update as the graphics stackAutoShaderSource setter.
fun stackAutoShaderSource(module: ComputeModulePointer, name: String, source: String) {
setStackAutoShaderSource(module, name, source, ShaderReloadType.COMPUTE)
}
fun stackAutoShaderSource(module: MeshModulePointer, name: String, source: String)
Bind StackSL source to a mesh module's stack.auto slot.
Unlike the graphics and compute setters this one currently goes through the full mesh reload
path rather than an ABI-preserving pipeline update, so it rebuilds the module and is
correspondingly heavier. (Ray-tracing modules have no stack.auto setter at all.)
fun stackAutoShaderSource(module: MeshModulePointer, name: String, source: String) {
setStackAutoShaderSource(module, name, source, ShaderReloadType.MESH)
}
data class ShaderIncludeRootRequest
One #include that landed inside a registered include root, handed to a
ShaderIncludeRootResolver to turn into text.
The path arrives pre-chewed: root has already been matched against the registry, includePath
is the remainder with ./.. collapsed and guaranteed not to climb above the root, and
requestedPath is what the shader literally wrote. Resolvers should key off includePath and use
requestedPath only for error messages.
data class ShaderIncludeRootRequest(
/** The registered root name that matched — the first path segment of a `root/…` include, or, for
* a relative include, the root the *including* file itself came from. */
val root: String,
/** The path within [root], normalised: separators unified to `/`, `.` and empty segments
* dropped, `..` resolved (and rejected if it would escape the root), no leading slash. This is
* the key a resolver should look up. */
val includePath: String,
/** Exactly what the `#include` said, root prefix and all. Useful in diagnostics; do not resolve
* against it. */
val requestedPath: String,
/** The resolved path of the file doing the including (a previous resolution's `resolvedPath`, or
* a `field/<box>-<id>/<property>.glsl` name for box source), or `null` when shaderc did not
* supply one. */
val requestingSourcePath: String?,
/** `RELATIVE` for `#include "…"`, `STANDARD` for `#include <…>`. Only `RELATIVE` includes are
* resolved relative to [requestingSourcePath]'s directory. */
val includeType: ShaderIncludeType,
/** How deep this include is nested, straight from shaderc. Informational — nothing in Field
* enforces a limit on it. */
val includeDepth: Long
)
fun interface ShaderIncludeRootResolver
Turns an include inside one registered root into source text. Register one under a name with
registerShaderIncludeRoot and every #include "thatName/whatever.glsl" in every box's shaders
goes through it.
Returning null does not mean "let someone else try": once a root has claimed a path the
caller escalates a null into a thrown, box-reported error naming the root. Throwing is
equivalent, so prefer whichever gives the better message. Called on whichever thread is compiling
— the main runloop for a reload, a background coroutine for an update — so anything slow in here
(a network fetch, say) is felt directly by that thread.
fun interface ShaderIncludeRootResolver {
fun resolve(request: ShaderIncludeRootRequest): ShaderIncludeResolution?
Resolve one include, or return null if this root does not have it (which the caller turns
into a hard error).
private val shaderIncludeRoots = ConcurrentHashMap<String, ShaderIncludeRootResolver>()
fun registerShaderIncludeRoot(root: String, resolver: ShaderIncludeRootResolver)
Claim a first path segment for a resolver, so #include "root/some/file.glsl" in any box's shader
is answered by resolver instead of being looked for as a box name.
The registry is process-global, not per document or per box: one registration serves every
open workspace, and registering the same name again silently replaces the previous resolver
(nothing warns). root must be a single path segment — leading and trailing slashes are trimmed,
but an embedded / throws, as does an empty name.
The trap worth knowing: roots are honoured by the #include path (resolved by shaderc while
compiling) but not by the template engine's {% property "…" %} tag, which only knows about
built-in includes and box.property references. A {% property "lygia/math/const.glsl" %} fails
with "no box named …" no matter what you have registered here.
fun registerShaderIncludeRoot(root: String, resolver: ShaderIncludeRootResolver) {
shaderIncludeRoots[normalizeShaderIncludeRootName(root)] = resolver
}
fun unregisterShaderIncludeRoot(root: String): ShaderIncludeRootResolver?
Remove an include root, returning the resolver that was registered under it (or null if none
was). Shaders already compiled keep working — this only affects the next compile.
fun unregisterShaderIncludeRoot(root: String): ShaderIncludeRootResolver? =
shaderIncludeRoots.remove(normalizeShaderIncludeRootName(root))
fun listShaderIncludeRoots(): Set<String> = shaderIncludeRoots.keys.toSortedSet()
Every include-root name currently registered, sorted — the quickest way to answer "why isn't
#include "lygia/…" resolving?" from a box.
data class ShaderRemoteIncludeCacheOptions
Where a URL-backed include root keeps its on-disk copies, and how patient it is with the network.
The cache is permanent and has no invalidation: once a file is written it is served from disk
forever, so picking up an upstream change means deleting the cached file (or the whole
cacheDirectory) yourself. Failures are not cached at all, so a 404 or a timeout is re-attempted
on every compile.
body — 68 lines, from line 1220
data class ShaderRemoteIncludeCacheOptions(
/** Directory the fetched includes are mirrored into, one file per include path. Created on
* demand. Resolved to an absolute, normalised path, and includes that would escape it are
* rejected — a remote server cannot talk you into writing outside this directory. */
val cacheDirectory: Path,
/** `HttpURLConnection` connect timeout, in milliseconds. */
val connectTimeoutMillis: Int = 5_000,
/** `HttpURLConnection` read timeout, in milliseconds. Both timeouts are felt by whichever thread
* is compiling — the main runloop, on the reload path — so the defaults are short. */
val readTimeoutMillis: Int = 5_000
)
private const val LYGIA_SHADER_ROOT = "lygia"
private const val LYGIA_REMOTE_BASE_URL = "https://lygia.xyz"
private fun defaultShaderIncludeCacheDirectory(): Path =
Paths.get(System.getProperty("user.home"), ".field4", "shader-include-cache")
private fun cachePathForShaderInclude(cacheDirectory: Path, includePath: String): Path {
val normalizedCacheDirectory = cacheDirectory.toAbsolutePath().normalize()
val normalizedIncludePath = normalizeShaderIncludePath(includePath)
val target = normalizedCacheDirectory.resolve(normalizedIncludePath).normalize()
require(target.startsWith(normalizedCacheDirectory)) {
"Shader include path '$includePath' escapes cache directory '$normalizedCacheDirectory'."
}
return target
}
private fun fetchRemoteShaderInclude(url: String, options: ShaderRemoteIncludeCacheOptions): String {
val connection = URI(url).toURL().openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.instanceFollowRedirects = true
connection.connectTimeout = options.connectTimeoutMillis
connection.readTimeout = options.readTimeoutMillis
connection.setRequestProperty("Accept", "text/plain, text/x-glsl, */*")
connection.setRequestProperty("User-Agent", "Field4 Shader Include Resolver")
notify("Fetching remote shader include for ${url}", "remoteShader")
return try {
val code = connection.responseCode
if (code != HttpURLConnection.HTTP_OK) {
val details = connection.errorStream?.bufferedReader(StandardCharsets.UTF_8)?.use { it.readText() }
?.trim()
?.takeIf { it.isNotEmpty() }
val message = buildString {
append("HTTP ")
append(code)
append(" while fetching '")
append(url)
append("'")
if (details != null) {
append(": ")
append(details)
}
}
notify("error ${code} fetching remote shader include for ${url} ${details}", "remoteShader")
throw IllegalArgumentException(message)
}
notify("fetched remote shader include for ${url}", "remoteShader")
connection.inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() }
} finally {
connection.disconnect()
}
}
fun shaderCachedUrlIncludeRootResolver
An include root that fetches baseUrl/<path> over HTTP the first time and reads the local copy
every time after — how Field consumes a shader library that lives on a website rather than on
disk.
Fetches are plain GETs with redirects followed; a non-200 throws (the body, if any, is appended
to the message) and is reported onto the box's shader lines like any other include failure.
Progress and failures are also pushed to the remoteShader notification channel, so the fetch is
visible in the UI. Because the fetch happens inline in shader compilation, a cold cache on a slow
link stalls whichever thread is compiling.
The returned resolver is not registered anywhere; hand it to registerShaderIncludeRoot. Note
that root is used for the cache subdirectory and for the resolvedPath reported back to
shaderc, which is what makes relative includes inside the fetched library resolve against the
same root.
fun shaderCachedUrlIncludeRootResolver(
root: String,
baseUrl: String,
cacheOptions: ShaderRemoteIncludeCacheOptions = ShaderRemoteIncludeCacheOptions(
cacheDirectory = defaultShaderIncludeCacheDirectory().resolve(normalizeShaderIncludeRootName(root))
)
): ShaderIncludeRootResolver {
val normalizedRoot = normalizeShaderIncludeRootName(root)
val normalizedBaseUrl = baseUrl.trimEnd('/')
val normalizedCacheDirectory = cacheOptions.cacheDirectory.toAbsolutePath().normalize()
return ShaderIncludeRootResolver { request ->
val includePath = normalizeShaderIncludePath(request.includePath)
val cacheFile = cachePathForShaderInclude(normalizedCacheDirectory, includePath)
if (Files.isRegularFile(cacheFile)) {
notify("remote shader is cached at ${cacheFile.toAbsolutePath()}", "remoteShader")
return@ShaderIncludeRootResolver ShaderIncludeResolution(
resolvedPath = canonicalShaderRootPath(normalizedRoot, includePath),
source = Files.readString(cacheFile)
)
}
val remoteUrl = "$normalizedBaseUrl/$includePath"
val source = fetchRemoteShaderInclude(remoteUrl, cacheOptions)
cacheFile.parent?.let { Files.createDirectories(it) }
Files.writeString(cacheFile, source, StandardCharsets.UTF_8)
ShaderIncludeResolution(
resolvedPath = canonicalShaderRootPath(normalizedRoot, includePath),
source = source
)
}
}
fun shaderLygiaIncludeRootResolver
The Lygia GLSL library as an include root, cached under
~/.field4/shader-include-cache/lygia.xyz by default — one call and #include "lygia/math/const.glsl"
works.
Just builds the resolver; registerLygiaIncludeRoot is the one-liner that also registers it under
the name lygia. Note the default cache directory is named after the host (lygia.xyz) rather
than after the root (lygia), so it differs from what shaderCachedUrlIncludeRootResolver would
have chosen for the same root.
fun shaderLygiaIncludeRootResolver(
cacheOptions: ShaderRemoteIncludeCacheOptions = ShaderRemoteIncludeCacheOptions(
cacheDirectory = defaultShaderIncludeCacheDirectory().resolve("lygia.xyz")
)
): ShaderIncludeRootResolver = shaderCachedUrlIncludeRootResolver(
root = LYGIA_SHADER_ROOT,
baseUrl = LYGIA_REMOTE_BASE_URL,
cacheOptions = cacheOptions
)
fun shaderLygiaIncludeRootResolver(cacheDirectory: Path): ShaderIncludeRootResolver
The Lygia include root with the cache pointed somewhere of your choosing and the default
timeouts — a per-project cache directory, say, rather than the shared one under ~/.field4.
fun shaderLygiaIncludeRootResolver(cacheDirectory: Path): ShaderIncludeRootResolver =
shaderLygiaIncludeRootResolver(ShaderRemoteIncludeCacheOptions(cacheDirectory = cacheDirectory))
fun shaderLygiaIncludeRootResolver(cacheDirectory: String): ShaderIncludeRootResolver
The Lygia include root with the cache directory given as a string. Interpreted by
Paths.get, so a relative path is relative to the JVM's working directory — not to the
workspace. Prefer an absolute path.
fun shaderLygiaIncludeRootResolver(cacheDirectory: String): ShaderIncludeRootResolver =
shaderLygiaIncludeRootResolver(Paths.get(cacheDirectory))
fun registerLygiaIncludeRoot
Register Lygia under the include root lygia, so every box's shaders can #include "lygia/…".
Process-global and idempotent-ish: calling it twice replaces the resolver, which is harmless but
also means a second call with different cacheOptions silently wins. Nothing fetches yet — the
first #include that needs a file it has not cached does.
fun registerLygiaIncludeRoot(
cacheOptions: ShaderRemoteIncludeCacheOptions = ShaderRemoteIncludeCacheOptions(
cacheDirectory = defaultShaderIncludeCacheDirectory().resolve("lygia.xyz")
)
) {
registerShaderIncludeRoot(LYGIA_SHADER_ROOT, shaderLygiaIncludeRootResolver(cacheOptions))
}
fun registerLygiaIncludeRoot(cacheDirectory: Path)
Register Lygia under the root lygia, caching into cacheDirectory.
fun registerLygiaIncludeRoot(cacheDirectory: Path) {
registerShaderIncludeRoot(LYGIA_SHADER_ROOT, shaderLygiaIncludeRootResolver(cacheDirectory))
}
fun registerLygiaIncludeRoot(cacheDirectory: String)
Register Lygia under the root lygia, caching into a directory named by string —
Paths.get-relative to the JVM's working directory, so prefer an absolute path.
fun registerLygiaIncludeRoot(cacheDirectory: String) {
registerLygiaIncludeRoot(Paths.get(cacheDirectory))
}
fun shaderFilesystemIncludeRootResolver(directory: Path): ShaderIncludeRootResolver
An include root served straight from a directory on disk: #include "root/sub/thing.glsl" reads
directory/sub/thing.glsl.
Read once per compile, not watched — editing a file on disk changes nothing until something
triggers a shader reload. Paths that would climb out of directory throw, and a path that simply
does not exist resolves to null, which the caller turns into a "cannot resolve include" error
against the including shader's line.
directory is captured absolute and normalised at construction, so the resolver keeps working
whatever the JVM's working directory does later — but that also means a relative directory is
frozen against the working directory at this moment, not against any workspace.
fun shaderFilesystemIncludeRootResolver(directory: Path): ShaderIncludeRootResolver {
val rootDirectory = directory.toAbsolutePath().normalize()
return ShaderIncludeRootResolver { request ->
val target = rootDirectory.resolve(request.includePath).normalize()
require(target.startsWith(rootDirectory)) {
"Include '${request.requestedPath}' escapes filesystem shader root '$rootDirectory'."
}
if (!Files.isRegularFile(target)) return@ShaderIncludeRootResolver null
ShaderIncludeResolution(
resolvedPath = canonicalShaderRootPath(request.root, request.includePath),
source = Files.readString(target)
)
}
}
fun shaderFilesystemIncludeRootResolver(directory: String): ShaderIncludeRootResolver
A directory-backed include root named by string. Paths.get-relative to the JVM's working
directory, which is not the workspace directory — pass an absolute path unless you are certain.
fun shaderFilesystemIncludeRootResolver(directory: String): ShaderIncludeRootResolver =
shaderFilesystemIncludeRootResolver(Paths.get(directory))
fun registerShaderFilesystemIncludeRoot(root: String, directory: Path)
Register directory as the include root root, so #include "root/…" reads files under it —
the one-liner for pulling a checked-out shader library into a workspace.
Process-global (see registerShaderIncludeRoot) and read-through, not watched: edit a file on disk and the change appears at the next shader reload, not immediately.
fun registerShaderFilesystemIncludeRoot(root: String, directory: Path) {
val normalizedRoot = normalizeShaderIncludeRootName(root)
registerShaderIncludeRoot(normalizedRoot, shaderFilesystemIncludeRootResolver(directory))
}
fun registerShaderFilesystemIncludeRoot(root: String, directory: String)
Register a directory as an include root, with the directory given as a string —
Paths.get-relative to the JVM's working directory, so prefer an absolute path.
fun registerShaderFilesystemIncludeRoot(root: String, directory: String) {
registerShaderFilesystemIncludeRoot(root, Paths.get(directory))
}
fun Box.fieldProbeHere
Create (or reconfigure) this box's GPU sample probe and hand it back for chaining — the Kotlin
half of #include "fieldProbe", which lets a shader call fieldProbe(x) and see the resulting
distribution on the CPU.
Typically fieldProbeHere(samples = 4096).display("probe"). The call is idempotent in the sense
that it reuses the box's existing probe rather than making a second one, but it clears any
previously registered display channel and onSnapshot callback first — that is deliberate, so a
re-run of the box does not accumulate stale registrations, and it is why .display(…) belongs on
the end of every call rather than being set up once.
Read-back happens off the render thread. Two different throttles are involved and only one is
unconditional: the display dispatch is always limited to probe.maxDisplayHz, but the GPU→CPU
read is limited only in the plain .display(…) case. Turn on continuous reads, register an
onSnapshot callback, or use no display channel at all, and shouldReadSnapshot returns true every
frame — so an onSnapshot that does real work is on the read path at full frame rate and can cost
you. The probe's buffer is bound into a module's parameters when that module is built or reloaded,
so calling this after the module exists needs a reload to take effect.
@param samples reservoir size pulled back to the CPU; clamped to 1..262144 by the probe
@param binding SSBO binding for FieldProbeBuffer — must match what the include declares
@param descriptorSet descriptor set the probe buffer lives in
@param enabled false leaves the instrumentation compiled in but recording nothing
@param ordinalRange restrict sampling to a slice of shader invocations; null resets to all of
them (it does not mean "leave the previous range alone")
fun Box.fieldProbeHere(
samples: Int = afield.graphics.vulkan.util.FIELD_PROBE_DEFAULT_SAMPLES,
binding: Int = afield.graphics.vulkan.util.FIELD_PROBE_DEFAULT_BINDING,
descriptorSet: Int = afield.graphics.vulkan.util.FIELD_PROBE_DEFAULT_SET,
enabled: Boolean = true,
ordinalRange: UIntRange? = null
): ShaderFieldProbe {
val probe = ensureFieldProbe()
probe.resetLiveCodingRegistrations()
probe.configure(
samples = samples,
binding = binding,
descriptorSet = descriptorSet,
enabled = enabled
)
if (ordinalRange != null) probe.ordinalRange(ordinalRange)
else probe.ordinalRange()
return probe
}
fun Box.fieldStatsHere
Declare that this box produces a GPU statistics block, and get the handle to read it back with —
the Kotlin half of #include "fieldStats" in a shader that records into it.
Unlike the probe, nothing is sampled back to the CPU by default beyond a few scalars per channel: the mean, variance, min/max and a quantile sketch are all reduced on the GPU, so a shader can feed its own statistics back into itself without a round trip. See the Field Probes page for the GLSL side.
This is a macro: written in a box it is expanded at compile time (which is why every argument
has to be an integer literal — see Box.macro_fieldStatsHere) and rewritten to
Box._fieldStatsHere. Calling it from ordinary Kotlin outside a box works too, since it simply
delegates.
A box that only reads someone else's stats calls Box.useFieldStats instead; calling this on
both boxes would allocate two unrelated blocks.
@param channels independent distributions, 1..64
@param sketchSize quantile-sketch resolution; a power of two in 16..256
@param maxPartials per-channel partial-record capacity — if a snapshot reports droppedPartials
above zero, this is the number to raise
@param descriptorSet descriptor set holding both stats buffers
@param partialBinding SSBO binding of the partial (per-invocation) buffer
@param resultBinding SSBO binding of the reduced result buffer
@param stateSlots GPU-side smoothed/EMA slots, 1..64
fun Box.fieldStatsHere(
channels: Int = 1,
sketchSize: Int = 256,
maxPartials: Int = 65_536,
descriptorSet: Int = 0,
partialBinding: Int = FIELD_STATS_DEFAULT_PARTIAL_BINDING,
resultBinding: Int = FIELD_STATS_DEFAULT_RESULT_BINDING,
stateSlots: Int = 8
): ShaderFieldStats = _fieldStatsHere(
channels = channels,
sketchSize = sketchSize,
maxPartials = maxPartials,
descriptorSet = descriptorSet,
partialBinding = partialBinding,
resultBinding = resultBinding,
stateSlots = stateSlots
)
fun Box._fieldStatsHere
The real work behind Box.fieldStatsHere — what the macro rewrites the call site to. Reach for it
directly only when you are driving a box from outside and do not want macro expansion.
Reuses the box's existing ShaderFieldStats when every parameter matches and the block belongs
to this box; change one number and you get a fresh block with fresh buffers, and the previous one
is simply dropped on the floor — ShaderFieldStats has no disposal call of its own. Either way the
box is re-marked as the block's producer, so a box that had been a consumer via
Box.useFieldStats becomes a producer again.
fun Box._fieldStatsHere(
channels: Int = 1,
sketchSize: Int = 256,
maxPartials: Int = 65_536,
descriptorSet: Int = 0,
partialBinding: Int = FIELD_STATS_DEFAULT_PARTIAL_BINDING,
resultBinding: Int = FIELD_STATS_DEFAULT_RESULT_BINDING,
stateSlots: Int = 8
): ShaderFieldStats {
_fieldStatsBinding?.stats?.takeIf {
it.box === this &&
it.channels == channels &&
it.sketchSize == sketchSize &&
it.maxPartials == maxPartials &&
it.descriptorSet == descriptorSet &&
it.partialBinding == partialBinding &&
it.resultBinding == resultBinding &&
it.stateSlots == stateSlots
}?.let { existing ->
_fieldStatsBinding = ShaderFieldStatsBinding(existing, producer = true)
return existing
}
val stats = ShaderFieldStats(
box = this,
channels = channels,
sketchSize = sketchSize,
maxPartials = maxPartials,
stateSlots = stateSlots,
descriptorSet = descriptorSet,
partialBinding = partialBinding,
resultBinding = resultBinding
)
_fieldStatsBinding = ShaderFieldStatsBinding(stats, producer = true)
return stats
}
fun Box.macro_fieldStatsHere(args: List<String>): Insert
Macro expansion for fieldStatsHere(...): parse the argument text, allocate the stats block now
(at expansion time, so a computeModuleHere in the same fragment can already resolve
#include "fieldStats"), and rewrite the call to Box._fieldStatsHere.
Because it has to know the numbers before anything is compiled, every argument must be an integer
literal — channels = n where n is a variable throws with that explanation rather than silently
using a default. Underscores in literals are fine (65_536). Positional and named arguments both
work, in the same order as Box.fieldStatsHere's parameters.
body — 46 lines, from line 1595
fun Box.macro_fieldStatsHere(args: List<String>): Insert {
val call = args.joinToString("").trim()
val body = call.removePrefix("(").removeSuffix(")").trim()
val defaults = linkedMapOf(
"channels" to 1,
"sketchSize" to 256,
"maxPartials" to 65_536,
"descriptorSet" to 0,
"partialBinding" to FIELD_STATS_DEFAULT_PARTIAL_BINDING,
"resultBinding" to FIELD_STATS_DEFAULT_RESULT_BINDING,
"stateSlots" to 8
)
val positionalNames = defaults.keys.toList()
var positionalIndex = 0
if (body.isNotEmpty()) {
body.split(',').map { it.trim() }.filter { it.isNotEmpty() }.forEach { argument ->
val (name, valueText) = if ('=' in argument) {
argument.substringBefore('=').trim() to argument.substringAfter('=').trim()
} else {
require(positionalIndex < positionalNames.size) {
"fieldStatsHere has too many positional arguments."
}
positionalNames[positionalIndex++] to argument
}
require(name in defaults) { "Unknown fieldStatsHere argument '$name'." }
val value = valueText.replace("_", "").toIntOrNull()
?: throw IllegalArgumentException(
"fieldStatsHere argument '$name' must be an integer literal so computeModuleHere can compile during macro expansion."
)
defaults[name] = value
}
}
_fieldStatsHere(
channels = defaults.getValue("channels"),
sketchSize = defaults.getValue("sketchSize"),
maxPartials = defaults.getValue("maxPartials"),
descriptorSet = defaults.getValue("descriptorSet"),
partialBinding = defaults.getValue("partialBinding"),
resultBinding = defaults.getValue("resultBinding"),
stateSlots = defaults.getValue("stateSlots")
)
return Insert(here = "_fieldStatsHere$call")
}
fun Box.useFieldStats(stats: ShaderFieldStats): ShaderFieldStats
Bind another box's ShaderFieldStats into this box as a consumer, so #include "fieldStats"
here emits the read-only GLSL instead of the recording GLSL.
The idiom is one box recording (fieldStatsHere) and another reading the reduced result —
consumerBox.useFieldStats(producerBox.fieldStatsHere(channels = 1)). Allocates nothing; it only
records the binding, so the buffers stay owned by the producing box. Returns stats unchanged for
chaining.
fun Box.useFieldStats(stats: ShaderFieldStats): ShaderFieldStats {
_fieldStatsBinding = ShaderFieldStatsBinding(stats, producer = false)
return stats
}
data class ShaderProbeAutoControlConfig
How Box.fieldProbeAutoControl turns probe read-backs into controller updates: when a batch is
big enough to act on, what counts as clipped, and where the diagnostics card goes.
The defaults assume the thing being controlled is a shader whose output should fill 0..1 — hence
clipLower = 0.0, clipUpper = 1.0 — and that you want to watch it happen (displayChannel =
"auto").
data class ShaderProbeAutoControlConfig(
/** Minimum samples in a read-back before the controller is allowed to learn from it. A snapshot
* with fewer is skipped entirely — `tick()` returns `null` and nothing is applied. */
val minSamples: Int = 128,
/** Values at or below this (within [clipEpsilon]) count as clipped low and are reported to the
* controller as a clip rate; `null` means "nothing clips at the bottom". */
val clipLower: Double? = 0.0,
/** Values at or above this (within [clipEpsilon]) count as clipped high; `null` means nothing
* clips at the top. With both bounds `null` the reported clip rate is a flat `0.0`. */
val clipUpper: Double? = 1.0,
/** Tolerance for the two clip comparisons — floating-point slop, not a percentage. */
val clipEpsilon: Double = 1.0e-6,
/** `true` applies the controller's *nominal* parameters, `false` (the default) its *applied*
* ones, which carry the small dither the controller needs to identify the system. Turn it on
* once identification has settled and the dither is visible in the output. */
val useNominalParams: Boolean = false,
/** Box output channel for the HTML diagnostics table (nominal vs applied parameters, objective,
* confidence, clip rate, p10/p50/p90). `null` disables the display entirely, which is what a
* headless test wants. */
val displayChannel: String? = "auto",
/** Whether `stop()` closes the display channel by default. */
val closeDisplayOnStop: Boolean = false,
/** Whether `stop()` clears the display channel's contents by default. */
val clearDisplayOnStop: Boolean = false,
/** Prefix of the runloop hook key, which is `"$runloopKeySuffix:${box.id}"`. Two controllers on
* one box need two different values here or the second silently displaces the first. */
val runloopKeySuffix: String = "shaderFieldAuto"
)
class ShaderFieldAutoController
The running loop that closes a shader's feedback circuit: read the probe, push the samples into an
adaptive controller, hand the new parameters to your apply lambda, and draw the diagnostics.
Build one with Box.fieldProbeAutoControl rather than directly — that is what registers it on the
box so a re-run can stop the previous one. Once installed it ticks once per runloop frame; a
headless test can leave install = false and call tick by hand, as
ShaderFieldAutoControlSmokeTest does.
Everything happens on whichever thread calls tick — the main runloop, normally — so the apply
lambda may write push constants directly without further synchronisation.
class ShaderFieldAutoController(
/** The box this controller belongs to: the source of the display channel and the identity in the
* runloop hook key. */
val box: Box,
/** The probe being read. [Box.fieldProbeAutoControl] has already put it into continuous-read mode,
* so it delivers a snapshot every frame rather than at the display rate. */
val probe: ShaderFieldProbe,
/** The adaptive controller doing the actual learning; its `parameterNames` label the diagnostics
* table and its `pushSamples` is what turns a batch into parameters. */
val controller: AdaptiveDistributionController,
/** The thresholds, clip bounds and display settings this controller was built with. Read on every
* tick, but the object is immutable — copy it and rebuild to change your mind. */
val config: ShaderProbeAutoControlConfig,
private val apply: (params: DoubleArray, output: ControlOutput, batch: FieldProbeScalarBatch) -> Unit
) {
private val hookKey = "${config.runloopKeySuffix}:${box.id}"
private var installed = false
private var lastSnapshot: FieldProbeSnapshot? = null
var tickCount: Int
How many ticks have actually done work. Ticks that found no new snapshot, or too few samples,
do not count — so a tickCount stuck at zero means the probe is not delivering, not that the
hook is missing.
var tickCount: Int = 0
private set
var lastBatch: FieldProbeScalarBatch?
The last probe batch the controller learned from, or null before the first useful tick.
Carries both the raw snapshot and the clip rate computed from config.
var lastBatch: FieldProbeScalarBatch? = null
private set
var lastOutput: ControlOutput?
The last controller output — nominal and applied parameters plus diagnostics — or null before
the first useful tick. The same object the apply lambda was handed.
var lastOutput: ControlOutput? = null
private set
fun tick(): ControlOutput?
Do one control step, returning the controller's output, or null if there was nothing to do.
Two of the reasons are boring: the probe has produced no new snapshot since the last tick
(compared by identity, so a frame with no dispatch is skipped rather than double-counted), or the
snapshot has fewer than config.minSamples samples. The third is not — a probe recording anything
other than scalars yields no scalar batch at all, so a shader probing a vec2/vec3/vec4
makes this a permanent silent no-op with tickCount stuck at 0. None of the three is an error, and
none advances tickCount.
When it does run: samples go into the controller, the resulting parameters go to the apply
lambda before the diagnostics are drawn, and the display channel is refreshed. Call it on the
thread that owns the resources apply touches.
fun tick(): ControlOutput? {
val batch = probe.readLatestScalarBatch(
minSamples = config.minSamples,
clipLower = config.clipLower,
clipUpper = config.clipUpper,
clipEpsilon = config.clipEpsilon
) ?: return null
if (batch.snapshot === lastSnapshot) return null
val output = controller.pushSamples(batch.batch.samples, batch.batch.clipRate)
val params = output.params(useNominalParams = config.useNominalParams)
apply(params, output, batch)
lastSnapshot = batch.snapshot
lastBatch = batch
lastOutput = output
tickCount += 1
config.displayChannel?.let { channel ->
box.display(
channel,
shaderFieldAutoSummaryHtml(
batch = batch,
output = output,
parameterNames = controller.parameterNames,
useNominalParams = config.useNominalParams
)
)
}
return output
}
fun install(runloop: Runloop = Runloop.runLoop): ShaderFieldAutoController
Install a preRun hook that calls tick once per frame, and return this for chaining.
Idempotent — a second call while already installed does nothing.
The hook is keyed "${config.runloopKeySuffix}:${box.id}", so one box gets one controller per
suffix. Note that the runloop is not remembered: if you install into a non-default runloop you
must pass the same one to stop, or the hook is never removed and keeps ticking.
fun install(runloop: Runloop = Runloop.runLoop): ShaderFieldAutoController {
if (installed) return this
runloop.preRun.setUnowned(hookKey, sequence {
while (true) {
tick()
yield(Unit)
}
})
installed = true
return this
}
fun stop
Remove the runloop hook, optionally tidy the display channel, and forget this controller on the
box. Returns this, and can be re-installed afterwards.
Two things it does not do: it does not reset the controller's learned state (re-installing
resumes where it left off), and it does not take the probe out of continuous-read mode. It also
only removes the hook from the runloop you pass — the same asymmetry install warns about.
fun stop(
runloop: Runloop = Runloop.runLoop,
closeDisplay: Boolean = config.closeDisplayOnStop,
clearDisplay: Boolean = config.clearDisplayOnStop
): ShaderFieldAutoController {
runloop.preRun.remove(hookKey)
installed = false
config.displayChannel?.let { channel ->
if (clearDisplay) box.displayClear(channel)
if (closeDisplay) box.displayClose(channel)
}
if (box._fieldProbeAutoController === this) {
box.properties.remove("_fieldProbeAutoController")
}
return this
}
}
fun Box.fieldProbeAutoControl
Wire this box's probe into an adaptive controller, so a shader parameter is steered every frame by what the shader's own output distribution turns out to be.
The one-liner version of "close the loop": the probe reports a batch of scalar samples, the
controller compares their distribution against the target its feature extractor encodes, and your
apply lambda writes the resulting parameters wherever they belong — typically
dispatch.push.set("offset", params[0].toFloat()).
Housekeeping done for you: any controller already on this box is stopped first (so re-running a box
cannot leave two of them fighting over the same push constants), the probe is switched into
continuous-read mode so a snapshot arrives every frame rather than at display rate, and the new
controller is stored on the box. With install = true (the default) it starts ticking immediately;
with false you own the pacing and call tick() yourself, which is what the smoke test does.
@param probe the probe to read; defaults to this box's, created on demand
@param controller the learner; the default is an AdaptiveDistributionController over uniform
quantile features, i.e. "make the output uniform over the clip range"
@param config thresholds, clip bounds and where the diagnostics card goes
@param install false to build it without registering the per-frame hook
@param runloop which runloop to install into — pass the same one to stop()
@param apply called on every useful tick with the parameters to apply, the full controller output,
and the batch they were derived from
body — 64 lines, from line 1861
fun Box.fieldProbeAutoControl(
probe: ShaderFieldProbe = ensureFieldProbe(),
controller: AdaptiveDistributionController = AdaptiveDistributionController(
ControllerConfig(),
UniformQuantileFeatureExtractor()
),
config: ShaderProbeAutoControlConfig = ShaderProbeAutoControlConfig(),
install: Boolean = true,
runloop: Runloop = Runloop.runLoop,
apply: (params: DoubleArray, output: ControlOutput, batch: FieldProbeScalarBatch) -> Unit
): ShaderFieldAutoController {
_fieldProbeAutoController?.stop(runloop = runloop)
probe.requireContinuousSnapshotReads()
return ShaderFieldAutoController(
box = this,
probe = probe,
controller = controller,
config = config,
apply = apply
).also { runtime ->
_fieldProbeAutoController = runtime
if (install) runtime.install(runloop)
}
}
private fun shaderFieldAutoSummaryHtml(
batch: FieldProbeScalarBatch,
output: ControlOutput,
parameterNames: List<String>,
useNominalParams: Boolean
): String {
fun format(value: Double): String = String.format(Locale.US, "%.4f", value)
val activeParams = output.params(useNominalParams)
val parameters = Table(listOf("parameter", "nominal", "applied"))
.rows(parameterNames.indices.map { i ->
listOf(parameterNames[i], output.nominalParams[i], output.appliedParams[i])
})
.format("nominal") { format(it as Double) }
.format("applied") { format(it as Double) }
val featureError = output.diagnostics.filteredFeatureError.joinToString(", ") { format(it) }
val p10 = batch.snapshot.percentile(0.10)
val p50 = batch.snapshot.percentile(0.50)
val p90 = batch.snapshot.percentile(0.90)
return FieldMarkdown.card(
"""
{muted:updates} ${output.diagnostics.pushIndex} ·
{muted:objective} ${format(output.diagnostics.objective)} ·
{muted:confidence} ${format(output.diagnostics.confidence)} ·
{muted:clip} ${format(batch.batch.clipRate)} ·
{muted:samples} ${batch.batch.samples.size} ·
{muted:p10/p50/p90} ${format(p10)} / ${format(p50)} / ${format(p90)}
{muted:filtered feature error} = [${featureError.md}]
{muted:active params} = [${activeParams.joinToString(", ") { format(it) }.md}]
${parameters.toHTML()}
"""
)
}
Public macro placeholders (expanded by MacroRegistry)
fun Box.moduleHere(p: Parameters): ModulePointer
Macro placeholder expanded to a graphics ModulePointer for the current box.
This should only appear at macro expansion sites; calling it directly throws.
fun Box.moduleHere(p: Parameters): ModulePointer {
throw MacroRegistry.MacroNotExpandedException("moduleHere should not be callable directly, but should be expanded as a macro")
}
fun Box.computeModuleHere(p: Parameters): ComputeModulePointer
Macro placeholder expanded to a ComputeModulePointer built from this box's compute property.
Exists only to be rewritten; calling it directly throws. Expansion seeds a trivial default compute
shader if the box has none, compiles it, and generates the ComputeMesh push-constant and buffer
accessors into the box's preamble.
fun Box.computeModuleHere(p: Parameters): ComputeModulePointer {
throw MacroRegistry.MacroNotExpandedException("computeModuleHere should not be callable directly, but should be expanded as a macro")
}
fun Box.meshModuleHere(p: Parameters): MeshModulePointer
Macro placeholder expanded to a MeshModulePointer built from this box's task, mesh and
fragment properties — all three are required, and defaults are seeded for any that are missing.
Exists only to be rewritten; calling it directly throws.
fun Box.meshModuleHere(p: Parameters): MeshModulePointer {
throw MacroRegistry.MacroNotExpandedException("meshModuleHere should not be callable directly, but should be expanded as a macro")
}
fun Box.rayTracingModuleHere(p: Parameters): RayTracingModulePointer
Macro placeholder expanded to a RayTracingModulePointer built from this box's raygen property
plus whichever of miss/closesthit/anyhit/intersection/callable are non-blank.
Exists only to be rewritten; calling it directly throws.
fun Box.rayTracingModuleHere(p: Parameters): RayTracingModulePointer {
throw MacroRegistry.MacroNotExpandedException("rayTracingModuleHere should not be callable directly, but should be expanded as a macro")
}
fun Box.moduleHere(p: Parameters.() -> Unit): ModulePointer {
Macro placeholder for the trailing-lambda form, moduleHere { primitiveType = triangles }.
The lambda configures the module's Parameters — blending, depth test, primitive type — but not
its shaders: assigning vertex inside it has no effect, because the prepared source from the box's
properties is written over the top. Seed shaders with once.vertex { } in an earlier //%-
fragment instead. Calling it directly throws.
fun Box.moduleHere(p: Parameters.() -> Unit): ModulePointer {
throw MacroRegistry.MacroNotExpandedException("moduleHere should not be callable directly, but should be expanded as a macro")
}
fun Box.computeModuleHere(p: Parameters.() -> Unit): ComputeModulePointer {
Macro placeholder for computeModuleHere { … }, the trailing-lambda form. The lambda configures
Parameters, not the shader source. Calling it directly throws.
fun Box.computeModuleHere(p: Parameters.() -> Unit): ComputeModulePointer {
throw MacroRegistry.MacroNotExpandedException("computeModuleHere should not be callable directly, but should be expanded as a macro")
}
fun Box.meshModuleHere(p: Parameters.() -> Unit): MeshModulePointer {
Macro placeholder for meshModuleHere { … }, the trailing-lambda form. Calling it directly
throws.
fun Box.meshModuleHere(p: Parameters.() -> Unit): MeshModulePointer {
throw MacroRegistry.MacroNotExpandedException("meshModuleHere should not be callable directly, but should be expanded as a macro")
}
fun Box.rayTracingModuleHere(p: Parameters.() -> Unit): RayTracingModulePointer {
Macro placeholder for rayTracingModuleHere { … }, the trailing-lambda form. Calling it directly
throws.
fun Box.rayTracingModuleHere(p: Parameters.() -> Unit): RayTracingModulePointer {
throw MacroRegistry.MacroNotExpandedException("rayTracingModuleHere should not be callable directly, but should be expanded as a macro")
}
fun Box.shadertoyHere(p: Parameters): ModulePointer
Macro placeholder expanded to a Shadertoy-shaped graphics module: a full-screen triangle, the
iResolution/iTime/iMouse/iDate uniform block already bound, four 1×1 dummy iChannel
textures, and depth testing off — so pasting a mainImage body into the box's fragment shader
mostly just works.
Exists only to be rewritten; calling it directly throws.
fun Box.shadertoyHere(p: Parameters): ModulePointer {
throw MacroRegistry.MacroNotExpandedException("shadertoyHere should not be callable directly, but should be expanded as a macro")
}
fun Box.shadertoyHere(p: Parameters.() -> Unit): ModulePointer {
Macro placeholder for shadertoyHere { … }, the trailing-lambda form. Calling it directly
throws.
fun Box.shadertoyHere(p: Parameters.() -> Unit): ModulePointer {
throw MacroRegistry.MacroNotExpandedException("shadertoyHere should not be callable directly, but should be expanded as a macro")
}
Utilities
private val glslSourceLineRegex = Regex("""\b(?:ERROR|WARNING):\s*\d+:(\d+):""", RegexOption.IGNORE_CASE)
private val fileLineRegex = Regex(""":[ \t]*(\d+):\s*(?:error|warning)\b""", RegexOption.IGNORE_CASE)
private val slangSourceLineRegex = Regex("""\((\d+)\):\s*(?:error|warning)\b""", RegexOption.IGNORE_CASE)
private val anyColonLineRegex = Regex(""":(\d+):""")
private val explicitLineRegex = Regex("""\bline\s+(\d+)\b""", RegexOption.IGNORE_CASE)
private val slangShaderPropertyByBase = mapOf(
"vertex" to "vertex_s",
"fragment" to "fragment_s",
"compute" to "compute_s",
"task" to "task_s",
"mesh" to "mesh_s",
"raygen" to "raygen_s",
"miss" to "miss_s",
"closesthit" to "closesthit_s",
"anyhit" to "anyhit_s",
"intersection" to "intersection_s",
"callable" to "callable_s"
)
private const val sharedVertexFragmentProperty = "vertex_fragment"
private data class ShaderIncludeRef(val boxName: String, val property: String) {
val key = "${boxName.trim()}.${property.trim()}"
}
fun numberFromErrorMessage(m: String): Int?
Dig a line number out of a shader (or Vulkan) error message, so the error can be pinned to a line in the box's editor rather than dumped in the log.
Tries four known shapes in order — glslang's ERROR: 0:12:, a file:12: error, Slang's
(12): error, and a bare line 12 — and only then falls back to "the last :N: anywhere in the
message", which is a guess and occasionally a wrong one. Returns null when even that finds
nothing; callers in this file treat null as line 0, i.e. "somewhere in this text".
body — 398 lines, from line 2055
fun numberFromErrorMessage(m: String): Int? {
glslSourceLineRegex.find(m)?.groupValues?.getOrNull(1)?.toIntOrNull()?.let { return it }
fileLineRegex.find(m)?.groupValues?.getOrNull(1)?.toIntOrNull()?.let { return it }
slangSourceLineRegex.find(m)?.groupValues?.getOrNull(1)?.toIntOrNull()?.let { return it }
explicitLineRegex.find(m)?.groupValues?.getOrNull(1)?.toIntOrNull()?.let { return it }
return anyColonLineRegex.findAll(m).mapNotNull { it.groupValues.getOrNull(1)?.toIntOrNull() }.lastOrNull()
}
private fun slangPropertyFor(baseProperty: String): String? = slangShaderPropertyByBase[baseProperty.lowercase()]
private fun shaderPropertyVariants(baseProperty: String): List<String> {
val normalized = baseProperty.lowercase()
val shared = if (normalized == "vertex" || normalized == "fragment") listOf(sharedVertexFragmentProperty) else emptyList()
return shared + listOfNotNull(normalized, slangPropertyFor(normalized))
}
private fun isSlangShaderProperty(property: String): Boolean =
property.lowercase() == sharedVertexFragmentProperty ||
property.lowercase().endsWith("_s") && baseShaderProperty(property) in slangShaderPropertyByBase
private fun baseShaderProperty(property: String): String = when (property.lowercase()) {
"vertex", "vertex_s" -> "vertex"
"fragment", "fragment_s" -> "fragment"
"compute", "compute_s" -> "compute"
"task", "task_s" -> "task"
"mesh", "mesh_s" -> "mesh"
"raygen", "raygen_s" -> "raygen"
"miss", "miss_s" -> "miss"
"closesthit", "closesthit_s" -> "closesthit"
"anyhit", "anyhit_s" -> "anyhit"
"intersection", "intersection_s" -> "intersection"
"callable", "callable_s" -> "callable"
else -> property.lowercase()
}
private fun defaultShaderProperty(kind: ShaderKind): String = when (kind) {
ShaderKind.VERTEX_SHADER -> "vertex"
ShaderKind.FRAGMENT_SHADER -> "fragment"
ShaderKind.COMPUTE_SHADER -> "compute"
ShaderKind.TASK_SHADER -> "task"
ShaderKind.MESH_SHADER -> "mesh"
ShaderKind.RAYGEN_SHADER -> "raygen"
ShaderKind.MISS_SHADER -> "miss"
ShaderKind.CLOSEST_HIT_SHADER -> "closesthit"
ShaderKind.ANY_HIT_SHADER -> "anyhit"
ShaderKind.INTERSECTION_SHADER -> "intersection"
ShaderKind.CALLABLE_SHADER -> "callable"
else -> "fragment"
}
private fun shaderKindForProperty(property: String, fallback: ShaderKind): ShaderKind = when (property.lowercase()) {
"vertex", "vertex_s" -> ShaderKind.VERTEX_SHADER
"fragment", "fragment_s" -> ShaderKind.FRAGMENT_SHADER
"compute", "compute_s" -> ShaderKind.COMPUTE_SHADER
"task", "task_s" -> ShaderKind.TASK_SHADER
"mesh", "mesh_s" -> ShaderKind.MESH_SHADER
"raygen", "raygen_s" -> ShaderKind.RAYGEN_SHADER
"miss", "miss_s" -> ShaderKind.MISS_SHADER
"closesthit", "closesthit_s" -> ShaderKind.CLOSEST_HIT_SHADER
"anyhit", "anyhit_s" -> ShaderKind.ANY_HIT_SHADER
"intersection", "intersection_s" -> ShaderKind.INTERSECTION_SHADER
"callable", "callable_s" -> ShaderKind.CALLABLE_SHADER
else -> fallback
}
private fun normalizeShaderIncludeRootName(root: String): String {
val normalized = root.trim().trim('/').trim('\\')
require(normalized.isNotEmpty()) { "Shader include root name cannot be empty." }
require('/' !in normalized && '\\' !in normalized) {
"Shader include root '$normalized' must be a single path segment."
}
return normalized
}
private fun normalizeShaderIncludePath(path: String): String {
val normalized = path.replace('\\', '/').trim()
require(normalized.isNotEmpty()) { "Shader include path cannot be empty." }
val parts = mutableListOf<String>()
normalized.split('/').forEach { part ->
when (part) {
"", "." -> Unit
".." -> {
require(parts.isNotEmpty()) { "Shader include path '$path' escapes its root." }
parts.removeLast()
}
else -> parts += part
}
}
return parts.joinToString("/")
}
private fun canonicalShaderRootPath(root: String, includePath: String): String =
buildString {
append(normalizeShaderIncludeRootName(root))
if (includePath.isNotBlank()) {
append('/')
append(normalizeShaderIncludePath(includePath))
}
}
private fun boxShaderSourceName(box: Box, property: String): String {
val boxLabel = box.name.trim().ifBlank { "box" }
.replace(Regex("[^A-Za-z0-9._-]+"), "_")
val propertyLabel = property.trim().ifBlank { "shader" }
.replace(Regex("[^A-Za-z0-9._-]+"), "_")
return "field/$boxLabel-${box.id}/$propertyLabel.glsl"
}
private fun resolveShaderIncludeRoot(request: ShaderIncludeRequest): ShaderIncludeResolution? {
val requestedPath = request.requestedPath.trim()
val requestingSource = request.requestingSource?.trim()?.takeIf { it.isNotEmpty() }
val directRoot = requestedPath.substringBefore('/', missingDelimiterValue = "")
if (directRoot.isNotEmpty() && shaderIncludeRoots.containsKey(directRoot)) {
val includePath = normalizeShaderIncludePath(requestedPath.substringAfter('/'))
val resolution = shaderIncludeRoots.getValue(directRoot).resolve(
ShaderIncludeRootRequest(
root = directRoot,
includePath = includePath,
requestedPath = requestedPath,
requestingSourcePath = requestingSource,
includeType = request.includeType,
includeDepth = request.includeDepth
)
)
requireNotNull(resolution) {
"Unable to resolve include '$requestedPath' from '${requestingSource ?: "<root>"}' in shader root '$directRoot'."
}
return resolution
}
if (request.includeType == ShaderIncludeType.RELATIVE && requestingSource != null) {
val requestingRoot = requestingSource.substringBefore('/', missingDelimiterValue = "")
if (shaderIncludeRoots.containsKey(requestingRoot)) {
val basePath = requestingSource.removePrefix("$requestingRoot/")
val baseDirectory = basePath.substringBeforeLast('/', "")
val resolvedRelativePath = normalizeShaderIncludePath(
listOfNotNull(baseDirectory.takeIf { it.isNotBlank() }, requestedPath)
.joinToString("/")
)
val resolution = shaderIncludeRoots.getValue(requestingRoot).resolve(
ShaderIncludeRootRequest(
root = requestingRoot,
includePath = resolvedRelativePath,
requestedPath = requestedPath,
requestingSourcePath = requestingSource,
includeType = request.includeType,
includeDepth = request.includeDepth
)
)
requireNotNull(resolution) {
"Unable to resolve include '$requestedPath' from '$requestingSource' in shader root '$requestingRoot'."
}
return resolution
}
}
return null
}
private fun parseShaderIncludeRef(path: String, defaultProperty: String): ShaderIncludeRef {
val trimmed = path.trim()
require(trimmed.isNotEmpty()) { "Empty shader include path." }
val dot = trimmed.lastIndexOf('.')
if (dot >= 0) {
require(dot > 0 && dot < trimmed.lastIndex) {
"Invalid shader include path '$trimmed'. Use '<box>' or '<box>.<property>'."
}
val boxName = trimmed.substring(0, dot).trim()
val property = trimmed.substring(dot + 1).trim()
require(boxName.isNotEmpty() && property.isNotEmpty()) {
"Invalid shader include path '$trimmed'."
}
return ShaderIncludeRef(boxName, property)
}
return ShaderIncludeRef(trimmed, defaultProperty)
}
private fun findBoxByNameForInclude(inside: Box, requestedName: String): Box? {
val name = requestedName.trim()
if (name.isEmpty()) return null
var found: Box? = null
inside.visitParents {
if (found == null && it.name == name && !it.getPropertyRaw("_tombstone").isTrue()) found = it
}
if (found != null) return found
val fringe = ArrayDeque<Box>()
val seen = mutableSetOf<Box>()
fringe.add(inside.root)
while (fringe.isNotEmpty()) {
val next = fringe.removeFirst()
if (!seen.add(next)) continue
if (next.name == name && !next.getPropertyRaw("_tombstone").isTrue()) return next
fringe.addAll(next.children)
}
return null
}
private fun getIncludePropertyValue(box: Box, property: String): String? {
val normalized = property.trim()
return when (normalized.lowercase()) {
"vertex" -> box.vertex
"vertex_fragment" -> box.vertex_fragment
"vertex_s" -> box.vertex_s
"fragment" -> box.fragment
"fragment_s" -> box.fragment_s
"compute" -> box.compute
"compute_s" -> box.compute_s
"task" -> box.task
"task_s" -> box.task_s
"mesh" -> box.mesh
"mesh_s" -> box.mesh_s
"raygen" -> box.raygen
"raygen_s" -> box.raygen_s
"miss" -> box.miss
"miss_s" -> box.miss_s
"closesthit" -> box.closesthit
"closesthit_s" -> box.closesthit_s
"anyhit" -> box.anyhit
"anyhit_s" -> box.anyhit_s
"intersection" -> box.intersection
"intersection_s" -> box.intersection_s
"callable" -> box.callable
"callable_s" -> box.callable_s
else -> box.getPropertyRaw(normalized) as? String
}
}
private fun Box.ensureFieldProbe(): ShaderFieldProbe {
val existing = _fieldProbe
if (existing != null) return existing
return ShaderFieldProbe(this).also { _fieldProbe = it }
}
private fun builtInShaderInclude(inside: Box, includePath: String): String? {
return when (includePath.trim()) {
FIELD_PROBE_INCLUDE_NAME -> inside.ensureFieldProbe().includeSource()
FIELD_STATS_INCLUDE_NAME -> inside._fieldStatsBinding?.let {
it.stats.includeSource(producer = it.producer)
}
?: throw IllegalStateException(
"Include '$FIELD_STATS_INCLUDE_NAME' requires fieldStatsHere(...) or useFieldStats(...)."
)
FIELD_QUANTILE_WARP_INCLUDE_NAME -> fieldQuantileWarpIncludeSource()
// Packaged pure functions must be available to macro expansion/completion before
// a resources box has executed. An explicit include-root registration still wins.
else -> if (includePath.startsWith("flow/") && !shaderIncludeRoots.containsKey("flow"))
afield.simulation.flow.FlowShaders.source(includePath.removePrefix("flow/"))
else if (includePath.startsWith("points/") && !shaderIncludeRoots.containsKey("points") &&
afield.graphics.vulkan.targets.PointShaders.has(includePath.removePrefix("points/")))
afield.graphics.vulkan.targets.PointShaders.source(includePath.removePrefix("points/"))
else if (includePath.startsWith("particles/") && !shaderIncludeRoots.containsKey("particles") &&
afield.simulation.particles.ParticleShaders.has(includePath.removePrefix("particles/")))
afield.simulation.particles.ParticleShaders.source(includePath.removePrefix("particles/"))
else if (includePath.startsWith("trail/") && !shaderIncludeRoots.containsKey("trail") &&
afield.simulation.agents.TrailShaders.has(includePath.removePrefix("trail/")))
afield.simulation.agents.TrailShaders.source(includePath.removePrefix("trail/"))
else if (includePath.startsWith("boids/") && !shaderIncludeRoots.containsKey("boids") &&
afield.simulation.particles.BoidShaders.has(includePath.removePrefix("boids/")))
afield.simulation.particles.BoidShaders.source(includePath.removePrefix("boids/"))
else if (includePath.startsWith("splat/") && !shaderIncludeRoots.containsKey("splat") &&
afield.graphics.scene.fabricated.SplatShader.hasInclude(includePath.removePrefix("splat/")))
afield.graphics.scene.fabricated.SplatShader.include(includePath.removePrefix("splat/"))
else null
}
}
private fun shaderIncludeResolverFor(inside: Box, defaultProperty: String): ShaderIncludeResolver = ShaderIncludeResolver { request ->
val includePath = request.requestedPath.trim()
builtInShaderInclude(inside, includePath)?.let { builtIn ->
return@ShaderIncludeResolver ShaderIncludeResolution(
resolvedPath = "field/include/$includePath.glsl",
source = builtIn
)
}
resolveShaderIncludeRoot(request)?.let { return@ShaderIncludeResolver it }
if (includePath.contains('/')) {
val source = request.requestingSource?.takeIf { it.isNotBlank() } ?: boxShaderSourceName(inside, defaultProperty)
throw IllegalArgumentException(
"Unable to resolve include '$includePath' from '$source': no registered include root named '${includePath.substringBefore('/')}'."
)
}
val includeRef = parseShaderIncludeRef(includePath, defaultProperty)
val targetBox = findBoxByNameForInclude(inside, includeRef.boxName)
?: throw IllegalArgumentException("Unable to resolve include '$includePath': no box named '${includeRef.boxName}'.")
val includeBody = getIncludePropertyValue(targetBox, includeRef.property)
?.takeIf { it.isNotBlank() }
?: throw IllegalArgumentException(
"Unable to resolve include '$includePath': '${includeRef.property}' on box '${targetBox.name}' is empty."
)
ShaderIncludeResolution(
resolvedPath = boxShaderSourceName(targetBox, includeRef.property),
source = includeBody
)
}
private fun attachFieldProbe(box: Box, parameters: Parameters) {
box._fieldProbe?.let { probe ->
parameters.fieldProbe = probe
val previousResolver = parameters.ssboResolver
parameters.ssboResolver = { requested ->
if (requested.name == FIELD_PROBE_BUFFER_NAME) {
probe.bufferParameters(parameters.renderer)
} else {
previousResolver(requested)
}
}
}
box._fieldStatsBinding?.let { binding ->
parameters.fieldStats = binding
val previousResolver = parameters.ssboResolver
parameters.ssboResolver = { requested ->
when (requested.name) {
FIELD_STATS_PARTIAL_BUFFER_NAME -> binding.stats.partialBufferParameters(parameters.renderer)
FIELD_STATS_RESULT_BUFFER_NAME -> binding.stats.resultBufferParameters(parameters.renderer)
else -> previousResolver(requested)
}
}
}
}
private fun preprocess(
inside: Box,
source: String,
kind: ShaderKind,
defaultProperty: String = defaultShaderProperty(kind),
includeStack: List<String> = emptyList(),
priorStages: Map<ShaderKind, PreparedShaderStage> = emptyMap(),
stackAutoEmitted: MutableMap<String, StackAutoShaderTarget> = mutableMapOf()
): String {
val sourceWithCopiedPushConstants = expandCopyPushConstantTags(
source = source,
kind = kind,
currentProperty = defaultProperty,
priorStages = priorStages
)
val includeResolver = IncludeResolver { includePath, line, _ ->
builtInShaderInclude(inside, includePath)?.let { builtIn ->
return@IncludeResolver buildString {
append(builtIn)
if (!builtIn.endsWith('\n')) append('\n')
append("#line ${line + 2}\n")
}
}
val includeRef = parseShaderIncludeRef(includePath, defaultProperty)
val includeKey = includeRef.key
if (includeStack.contains(includeKey)) {
val chain = (includeStack + includeKey).joinToString(" -> ")
throw IllegalArgumentException("Include cycle at line $line while resolving '$includePath': $chain")
}
val targetBox = findBoxByNameForInclude(inside, includeRef.boxName)
?: throw IllegalArgumentException(
"Unable to resolve include '$includePath' at line $line: no box named '${includeRef.boxName}'."
)
val includeBody = getIncludePropertyValue(targetBox, includeRef.property)
?.takeIf { it.isNotBlank() }
?: throw IllegalArgumentException(
"Unable to resolve include '$includePath' at line $line: '${includeRef.property}' on box '${targetBox.name}' is empty."
)
val nestedKind = shaderKindForProperty(includeRef.property, kind)
val expandedInclude = preprocess(
inside,
includeBody,
nestedKind,
defaultProperty = includeRef.property,
includeStack = includeStack + includeKey,
priorStages = priorStages,
stackAutoEmitted = stackAutoEmitted
)
buildString {
append(expandedInclude)
if (!expandedInclude.endsWith('\n')) append('\n')
append("#line ${line + 2}\n")
}
}
return runShaderTemplate(
stackAutoSlotsAfterPushConstants(sourceWithCopiedPushConstants),
listOf(stackAutoTemplateResolverFor(inside, stackAutoEmitted, kind)) + getShaderTemplateResolverForBox(inside),
includeResolver
)
}
fun preprocess(inside: Box, code: String, kind: ShaderKind): String
Run one shader stage's source through Field's preprocessor and hand back the GLSL that shaderc would
see — the thing to call when you are building a module by hand instead of through a
moduleHere-family macro.
Three expansions happen here, all resolved against inside and its ancestors: {{ slot }} template
expressions (from Box.shaderTemplate and the global registry), {% property "box.property" %} tags
(recursively preprocessed, cycle-detected, and fitted with #line directives so error line numbers
still point at your text), and {{ stack.auto.name }} slots compiled from StackSL. Built-in
includes — fieldProbe, fieldStats, quantileWarp — resolve here too, and asking for fieldStats
without a Box.fieldStatsHere or Box.useFieldStats binding throws.
Two things this overload cannot do, because it has no notion of a previous stage or a
registered root: {% copyPC %} throws (there is no prior stage to copy a push-constant block from),
and {% property "someRoot/file.glsl" %} fails as "no box named …" — registered include roots are
only reachable from a real #include during compilation, not from the template tag.
Failures throw rather than being reported onto the box; the reload path catches them for you, a hand-rolled caller must not.
body — 170 lines, from line 2474
fun preprocess(inside: Box, code: String, kind: ShaderKind): String {
return preprocess(inside, code, kind, defaultShaderProperty(kind), emptyList())
}
private val copyPushConstantTagRegex = Regex("""\{%\s*copyPC\s*%}""")
private val layoutKeywordRegex = Regex("""\blayout\b""")
private val pushConstantLayoutRegex = Regex("""\bpush_constant\b""")
private fun expandCopyPushConstantTags(
source: String,
kind: ShaderKind,
currentProperty: String,
priorStages: Map<ShaderKind, PreparedShaderStage>
): String {
if (!copyPushConstantTagRegex.containsMatchIn(source)) return source
val previousStage = previousStageForCopyPushConstants(kind, priorStages)
?: throw IllegalArgumentException(
"'{% copyPC %}' in '$currentProperty' requires a previous prepared shader stage. " +
"Supported flows are vertex -> fragment and task -> mesh -> fragment."
)
val declaration = extractPushConstantLayoutDeclaration(previousStage.glslSource, previousStage.sourceName)
return copyPushConstantTagRegex.replace(source, declaration)
}
private fun previousStageForCopyPushConstants(
kind: ShaderKind,
priorStages: Map<ShaderKind, PreparedShaderStage>
): PreparedShaderStage? = when (kind) {
ShaderKind.MESH_SHADER -> priorStages[ShaderKind.TASK_SHADER]
ShaderKind.FRAGMENT_SHADER -> priorStages[ShaderKind.MESH_SHADER] ?: priorStages[ShaderKind.VERTEX_SHADER]
ShaderKind.MISS_SHADER,
ShaderKind.CLOSEST_HIT_SHADER,
ShaderKind.ANY_HIT_SHADER,
ShaderKind.INTERSECTION_SHADER,
ShaderKind.CALLABLE_SHADER -> priorStages[ShaderKind.RAYGEN_SHADER]
else -> null
}
private fun extractPushConstantLayoutDeclaration(source: String, sourceName: String): String {
val matches = mutableListOf<String>()
var searchFrom = 0
while (true) {
val layoutMatch = layoutKeywordRegex.find(source, searchFrom) ?: break
val layoutStart = layoutMatch.range.first
var cursor = skipShaderTrivia(source, layoutMatch.range.last + 1)
if (cursor >= source.length || source[cursor] != '(') {
searchFrom = layoutStart + 1
continue
}
val layoutEnd = findMatchingDelimiter(source, cursor, '(', ')')
?: throw IllegalArgumentException("Unclosed layout(...) declaration while scanning '$sourceName'.")
val layoutArgs = source.substring(cursor + 1, layoutEnd)
if (!pushConstantLayoutRegex.containsMatchIn(layoutArgs)) {
searchFrom = layoutEnd + 1
continue
}
cursor = skipShaderTrivia(source, layoutEnd + 1)
if (!startsWithShaderKeyword(source, cursor, "uniform")) {
throw IllegalArgumentException(
"Found layout(... push_constant ...) in '$sourceName' but could not parse the following uniform block."
)
}
cursor = skipShaderTrivia(source, cursor + "uniform".length)
val blockStart = findNextShaderChar(source, cursor, '{')
?: throw IllegalArgumentException(
"Push constant layout in '$sourceName' is missing the '{' that starts its uniform block."
)
val blockEnd = findMatchingDelimiter(source, blockStart, '{', '}')
?: throw IllegalArgumentException("Unclosed push constant uniform block while scanning '$sourceName'.")
val declarationEnd = findNextShaderChar(source, blockEnd + 1, ';')
?: throw IllegalArgumentException("Push constant uniform block in '$sourceName' is missing its trailing ';'.")
matches += source.substring(layoutStart, declarationEnd + 1).trimEnd()
searchFrom = declarationEnd + 1
}
return when (matches.size) {
0 -> throw IllegalArgumentException("'{% copyPC %}' could not find a push constant layout declaration in '$sourceName'.")
1 -> matches.single()
else -> throw IllegalArgumentException(
"'{% copyPC %}' expected exactly one push constant layout declaration in '$sourceName', found ${matches.size}."
)
}
}
private fun skipShaderTrivia(source: String, start: Int): Int {
var index = start
while (index < source.length) {
when {
source[index].isWhitespace() -> index += 1
source.startsWith("//", index) -> {
val newline = source.indexOf('\n', index + 2)
index = if (newline == -1) source.length else newline + 1
}
source.startsWith("/*", index) -> {
val end = source.indexOf("*/", index + 2)
if (end == -1) return source.length
index = end + 2
}
else -> return index
}
}
return index
}
private fun findMatchingDelimiter(source: String, openIndex: Int, openChar: Char, closeChar: Char): Int? {
var depth = 0
var index = openIndex
while (index < source.length) {
when {
source.startsWith("//", index) -> {
val newline = source.indexOf('\n', index + 2)
index = if (newline == -1) source.length else newline + 1
continue
}
source.startsWith("/*", index) -> {
val end = source.indexOf("*/", index + 2)
if (end == -1) return null
index = end + 2
continue
}
source[index] == openChar -> depth += 1
source[index] == closeChar -> {
depth -= 1
if (depth == 0) return index
}
}
index += 1
}
return null
}
private fun startsWithShaderKeyword(source: String, index: Int, keyword: String): Boolean {
if (index < 0 || index + keyword.length > source.length) return false
if (!source.regionMatches(index, keyword, 0, keyword.length)) return false
val before = source.getOrNull(index - 1)
val after = source.getOrNull(index + keyword.length)
return !before.isShaderIdentifierPart() && !after.isShaderIdentifierPart()
}
private fun findNextShaderChar(source: String, start: Int, target: Char): Int? {
var index = start
while (index < source.length) {
when {
source.startsWith("//", index) -> {
val newline = source.indexOf('\n', index + 2)
index = if (newline == -1) source.length else newline + 1
continue
}
source.startsWith("/*", index) -> {
val end = source.indexOf("*/", index + 2)
if (end == -1) return null
index = end + 2
continue
}
source[index] == target -> return index
else -> index += 1
}
}
return null
}
private fun Char?.isShaderIdentifierPart(): Boolean =
this != null && (this == '_' || this.isLetterOrDigit())
What one stage's compile produced: its Introspection, or null when the compile failed.
hadError is the authoritative flag — introspection == null implies it, but callers test
hadError because a stage can fail after producing partial reflection. The compiling itself, and
the box.error reporting, live in compileStage; this is only the value it hands back.
source — 74 lines, from line 2652
private data class CompileResult(val introspection: Introspection?, val hadError: Boolean)
private enum class ShaderReloadType {
GRAPHICS,
COMPUTE,
MESH,
RAY_TRACING
}
private data class ShaderReloadKey(
val boxIdentity: Int,
val type: ShaderReloadType
)
private val shaderReloadScope =
CoroutineScope(SupervisorJob() + Runloop.miscPool.asCoroutineDispatcher())
private val shaderReloadGenerations = ConcurrentHashMap<ShaderReloadKey, AtomicLong>()
private val shaderReloadErrorGate = ThreadLocal<(() -> Boolean)?>()
private fun currentShaderReloadMayReport(): Boolean =
shaderReloadErrorGate.get()?.invoke() ?: true
private fun nextShaderGeneration(box: Box, type: ShaderReloadType): Pair<ShaderReloadKey, Long> {
val key = ShaderReloadKey(System.identityHashCode(box), type)
val generation = shaderReloadGenerations.computeIfAbsent(key) { AtomicLong(0L) }.incrementAndGet()
return key to generation
}
private fun isCurrentShaderGeneration(key: ShaderReloadKey, generation: Long): Boolean =
shaderReloadGenerations[key]?.get() == generation
private inline fun <T> withShaderReloadErrorGate(key: ShaderReloadKey, generation: Long, block: () -> T): T {
val previous = shaderReloadErrorGate.get()
shaderReloadErrorGate.set { isCurrentShaderGeneration(key, generation) }
try {
return block()
} finally {
shaderReloadErrorGate.set(previous)
}
}
private data class StackAutoShaderUpdateState(
val inFlight: AtomicBoolean = AtomicBoolean(false),
val pending: AtomicBoolean = AtomicBoolean(false)
)
private val stackAutoShaderUpdateStates = ConcurrentHashMap<ShaderReloadKey, StackAutoShaderUpdateState>()
private suspend fun runStackAutoShaderUpdate(box: Box, type: ShaderReloadType, key: ShaderReloadKey, generation: Long): Boolean =
when (type) {
ShaderReloadType.GRAPHICS -> updateShader(box, key, generation)
ShaderReloadType.COMPUTE -> updateComputeShader(box, key, generation)
ShaderReloadType.MESH -> withContext(Runloop.runLoop.main) {
if (!isCurrentShaderGeneration(key, generation)) return@withContext false
withShaderReloadErrorGate(key, generation) {
reloadMeshShader(box)
}
}
ShaderReloadType.RAY_TRACING -> withContext(Runloop.runLoop.main) {
if (!isCurrentShaderGeneration(key, generation)) return@withContext false
withShaderReloadErrorGate(key, generation) {
reloadRayTracingShader(box)
}
}
}
private fun stackAutoShaderTypeLabel(type: ShaderReloadType): String =
when (type) {
ShaderReloadType.GRAPHICS -> "shader"
ShaderReloadType.COMPUTE -> "compute shader"
ShaderReloadType.MESH -> "mesh shader"
ShaderReloadType.RAY_TRACING -> "ray tracing shader"
}
val stackAutoShaderUpdated
Called on Field's main thread when a box's queued stack.auto pipeline update has finished and nothing
newer is queued behind it: true when the new pipeline is in place (the next frame draws it), false when
the update failed and the old one still draws. A slot change is only queued when its setter returns; a
caller that must see the result waits for this.
body — 150 lines, from line 2733
val stackAutoShaderUpdated = java.util.concurrent.CopyOnWriteArrayList<(box: Box, success: Boolean) -> Unit>()
private fun requestStackAutoShaderUpdate(box: Box, type: ShaderReloadType) {
val updateKey = ShaderReloadKey(System.identityHashCode(box), type)
val state = stackAutoShaderUpdateStates.computeIfAbsent(updateKey) {
StackAutoShaderUpdateState()
}
state.pending.set(true)
if (!state.inFlight.compareAndSet(false, true)) {
nextShaderGeneration(box, type)
return
}
shaderReloadScope.launch {
try {
do {
state.pending.set(false)
val (key, generation) = nextShaderGeneration(box, type)
val label = stackAutoShaderTypeLabel(type)
val success = try {
runStackAutoShaderUpdate(box, type, key, generation)
} catch (t: Throwable) {
lprintln(
"error,graphics",
"unexpected error updating stack.auto $label in '${box.name}' (${box.id}): ${t.message}"
)
if (isCurrentShaderGeneration(key, generation)) {
withContext(Runloop.runLoop.main) {
box.displayError(t.message ?: "stack.auto $label update failed", "code", -1)
}
}
false
}
if (isCurrentShaderGeneration(key, generation)) {
withContext(Runloop.runLoop.main) {
notify(
if (success) "Updated stack.auto $label in '${box.name}'"
else "Stack.auto $label update failed in '${box.name}'",
"shader"
)
if (!state.pending.get()) stackAutoShaderUpdated.forEach { runCatching { it(box, success) } }
}
}
} while (state.pending.get())
} finally {
state.inFlight.set(false)
if (state.pending.get()) requestStackAutoShaderUpdate(box, type)
}
}
}
private data class PreparedShaderStage(
val kind: ShaderKind,
val baseProperty: String,
val sourceProperty: String,
val sourceName: String,
val source: String,
val preprocessedSource: String,
val glslSource: String,
val usedSlang: Boolean,
val spirv: ShaderSPIRVUtils.SPIRV? = null,
val reflectionJson: String? = null
) {
val logLabel: String
get() = if (usedSlang) "$baseProperty shader (slang)" else "$baseProperty shader"
}
private fun shaderPropertyValue(box: Box, property: String): String? = when (property.lowercase()) {
"vertex" -> box.vertex
"vertex_fragment" -> box.vertex_fragment
"vertex_s" -> box.vertex_s
"fragment" -> box.fragment
"fragment_s" -> box.fragment_s
"compute" -> box.compute
"compute_s" -> box.compute_s
"task" -> box.task
"task_s" -> box.task_s
"mesh" -> box.mesh
"mesh_s" -> box.mesh_s
"raygen" -> box.raygen
"raygen_s" -> box.raygen_s
"miss" -> box.miss
"miss_s" -> box.miss_s
"closesthit" -> box.closesthit
"closesthit_s" -> box.closesthit_s
"anyhit" -> box.anyhit
"anyhit_s" -> box.anyhit_s
"intersection" -> box.intersection
"intersection_s" -> box.intersection_s
"callable" -> box.callable
"callable_s" -> box.callable_s
else -> box.getPropertyRaw(property) as? String
}
private fun hasShaderSource(box: Box, baseProperty: String): Boolean =
shaderPropertyVariants(baseProperty).any { property ->
!shaderPropertyValue(box, property).isNullOrBlank()
}
private fun sourcePropertyForStage(box: Box, baseProperty: String): String {
val normalized = baseProperty.lowercase()
if ((normalized == "vertex" || normalized == "fragment") && !shaderPropertyValue(box, sharedVertexFragmentProperty).isNullOrBlank()) {
return sharedVertexFragmentProperty
}
val slangProperty = slangPropertyFor(normalized)
val slangSource = slangProperty?.let { shaderPropertyValue(box, it) }
return if (!slangSource.isNullOrBlank()) slangProperty else normalized
}
private fun assignPreparedShaderStage(box: Box, stage: PreparedShaderStage) {
when (stage.baseProperty) {
"vertex" -> box._vertex = stage.glslSource
"fragment" -> box._fragment = stage.glslSource
"compute" -> box._compute = stage.glslSource
"task" -> box._task = stage.glslSource
"mesh" -> box._mesh = stage.glslSource
"raygen" -> box._raygen = stage.glslSource
"miss" -> box._miss = stage.glslSource
"closesthit" -> box._closesthit = stage.glslSource
"anyhit" -> box._anyhit = stage.glslSource
"intersection" -> box._intersection = stage.glslSource
"callable" -> box._callable = stage.glslSource
}
}
private fun clearShaderErrorsForBaseProperties(box: Box, vararg baseProperties: String) {
val properties = baseProperties
.flatMap(::shaderPropertyVariants)
.distinct()
.toTypedArray()
clearShaderErrors(box, *properties)
}
private fun reportShaderStageError(
box: Box,
propertyForUI: String,
logLabel: String,
message: String,
throwable: Throwable? = null,
lineOverride: Int? = null
) {
val trimmed = message.trim().ifBlank { throwable?.message ?: logLabel }
lprintln("error,graphics", "error during $logLabel $trimmed")
throwable?.let { lprintln("error,graphics", it.stackTraceToString()) }
if (currentShaderReloadMayReport()) {
box.displayError(trimmed, propertyForUI, lineOverride ?: numberFromErrorMessage(trimmed) ?: 0)
}
}
The line in the box's own code that a failing stack.auto diagnostic should mark, or null.
Two corrections are needed to get from a StackSL diagnostic to an editor marker, and without both the marker lands somewhere arbitrary and reads as noise.
The number is a column, not a line. StackSL renders a location as line:column
(StackSL.kt:16), so a diagnostic reads error: 3:11: Unknown StackSL word 'fb'. numberFromErrorMessage
tries glslSourceLineRegex first, which is written for glslang's ERROR: 0:12: where the leading
number is a file index and the second is the line. The two formats are the same shape and mean
different things, so the generic helper confidently returns 11 -- the column -- as a line number.
And the line is relative to the StackSL, not the box. A slot is usually assigned from a string literal partway down a Kotlin box, so StackSL line 3 is not box line 3. This locates the program inside the box's code and offsets by where it starts.
Returns null when the source is not found verbatim -- it may have been built programmatically -- and the caller then falls back to the generic extraction rather than inventing a position.
source — 134 lines, from line 2903
private fun stackAutoErrorLine(box: Box, source: String, message: String?): Int? {
val stackSlLine = message
?.let { Regex("""(?:error|warning):\s*(\d+):\d+:""", RegexOption.IGNORE_CASE).find(it) }
?.groupValues?.getOrNull(1)?.toIntOrNull()
?: return null
val code = box.code?.takeIf { it.isNotEmpty() } ?: return null
// Match the source VERBATIM first. StackSL counts lines from the start of the string it was
// handed, and a `"""` literal usually begins with a newline, so its line 1 is the empty remainder
// of the assignment's own line. Trimming that newline away here would shift every marker up by
// one -- which it did, on the first attempt.
val at = code.indexOf(source).takeIf { it >= 0 }
?: code.indexOf(source.trimEnd()).takeIf { it >= 0 }
?: code.indexOf(source.trim()).takeIf { it >= 0 }
?: return null
val startLine = code.substring(0, at).count { it == '\n' } + 1
return startLine + stackSlLine - 1
}
private fun prepareShaderStage(
box: Box,
kind: ShaderKind,
baseProperty: String,
priorStages: Map<ShaderKind, PreparedShaderStage> = emptyMap()
): PreparedShaderStage? {
val sourceProperty = sourcePropertyForStage(box, baseProperty)
val source = shaderPropertyValue(box, sourceProperty)?.takeIf { it.isNotBlank() } ?: run {
reportShaderStageError(
box,
sourceProperty,
"$baseProperty shader setup",
"No shader source found for property '$sourceProperty'."
)
return null
}
val preprocessedSource = try {
preprocess(box, source, kind, defaultProperty = sourceProperty, priorStages = priorStages)
} catch (t: Throwable) {
reportShaderStageError(
box,
sourceProperty,
"$baseProperty shader preprocessing",
t.message ?: "while preprocessing $baseProperty shader",
t
)
return null
}
val usedSlang = isSlangShaderProperty(sourceProperty)
var spirv: ShaderSPIRVUtils.SPIRV? = null
var reflectionJson: String? = null
val glslSource = if (sourceProperty == sharedVertexFragmentProperty) {
try {
val compiled = SlangCompiler.compileSharedStage(preprocessedSource, kind, "$sourceProperty.slang")
spirv = compiled.spirv
reflectionJson = compiled.reflectionJson
compiled.glslSource
} catch (t: Throwable) {
reportShaderStageError(
box,
sourceProperty,
"$baseProperty shader transpilation",
t.message ?: "slangc failed for $sourceProperty",
t
)
return null
}
} else if (usedSlang) {
try {
SlangCompiler.compileToGlsl(preprocessedSource, kind, "$sourceProperty.slang")
} catch (t: Throwable) {
reportShaderStageError(
box,
sourceProperty,
"$baseProperty shader transpilation",
t.message ?: "slangc failed for $sourceProperty",
t
)
return null
}
} else {
preprocessedSource
}
return PreparedShaderStage(
kind = kind,
baseProperty = baseProperty,
sourceProperty = sourceProperty,
sourceName = boxShaderSourceName(box, sourceProperty),
source = source,
preprocessedSource = preprocessedSource,
glslSource = glslSource,
usedSlang = usedSlang,
spirv = spirv,
reflectionJson = reflectionJson
)
}
private fun compileStage(
box: Box,
stage: PreparedShaderStage
): CompileResult {
var hadError = false
val includeResolver = shaderIncludeResolverFor(box, stage.sourceProperty)
val handler = { message: String, throwable: Throwable ->
val throwableMsg = throwable.message?.let { " $it" } ?: ""
lprintln("error,graphics", "error during ${stage.logLabel} compilation $message$throwableMsg")
lprintln("error,graphics", throwable.stackTraceToString())
if (currentShaderReloadMayReport()) {
box.displayError(message, stage.sourceProperty, numberFromErrorMessage(message) ?: 0)
}
hadError = true
}
val i = if (stage.spirv != null && stage.reflectionJson != null) {
Introspection.fromSlangReflection(
source = stage.glslSource,
kind = stage.kind,
spirv = stage.spirv,
reflectionJson = stage.reflectionJson,
sourceName = stage.sourceName,
includeResolver = includeResolver,
errorHandler = handler
)
} else {
Introspection(
stage.glslSource,
stage.kind,
errorHandler = handler,
sourceName = stage.sourceName,
includeResolver = includeResolver
)
}
return CompileResult(if (hadError) null else i, hadError)
}
---- Preamble assembly: the machinery applyInsertToPreamble uses to merge an Insert's generated lines into a box's preamble in place. (This was a section heading that a later declaration turned into a KDoc block for the regex below; it is a plain comment now.)
Matches a leading /*marker*/ on one preamble line, capturing the marker text.
Every generated preamble line is stamped with such a marker by prefixed, and it is the marker —
not the code after it — that identifies the line for dedupe and replacement. Used by
preambleLineKey to extract it and by isImportLine to look past it.
source — 95 lines, from line 3049
private val preamblePrefixRegex = Regex("^\\s*/\\*(.*?)\\*/\\s*")
private const val preambleBlockStartMarker = "/*start*/"
private const val preambleBlockEndMarker = "/*end*/"
private data class PreambleSegment(val lines: List<String>)
private fun preambleLineKey(line: String): String {
val normalized = line.trimEnd()
if (normalized.isBlank()) return ""
val prefix = preamblePrefixRegex.find(normalized)?.groupValues?.getOrNull(1)?.trim()
return if (prefix.isNullOrEmpty()) normalized else prefix
}
private fun isImportLine(line: String): Boolean {
val withoutPrefix = line.replaceFirst(preamblePrefixRegex, "")
return withoutPrefix.trimStart().startsWith("import ")
}
private fun stripPreambleBlockMarkers(line: String): String =
line.replace(preambleBlockStartMarker, "").replace(preambleBlockEndMarker, "").trim()
private fun splitPreambleSegments(lines: List<String>): List<PreambleSegment> {
val segments = mutableListOf<PreambleSegment>()
var currentBlock: MutableList<String>? = null
fun flushCurrentBlock() {
val block = currentBlock ?: return
if (block.isNotEmpty()) segments += PreambleSegment(block.toList())
currentBlock = null
}
lines.forEach { line ->
val normalized = line.trimEnd()
if (normalized.isBlank()) return@forEach
if (currentBlock != null) {
currentBlock?.add(normalized)
if (normalized.contains(preambleBlockEndMarker)) {
flushCurrentBlock()
}
return@forEach
}
if (normalized.contains(preambleBlockStartMarker)) {
currentBlock = mutableListOf(normalized)
if (normalized.contains(preambleBlockEndMarker)) {
flushCurrentBlock()
}
} else {
segments += PreambleSegment(listOf(normalized))
}
}
flushCurrentBlock()
return segments
}
private fun preambleSegmentKey(segment: PreambleSegment): String {
val prefixedLine = segment.lines
.asSequence()
.map(::stripPreambleBlockMarkers)
.firstOrNull { preamblePrefixRegex.containsMatchIn(it) }
if (prefixedLine != null) return preambleLineKey(prefixedLine)
// A supplier tag is provenance, not identity, as in `prefixFor`: a box's own `prop<T>(x)` and the
// same declaration inherited from a parent are one line. Keyed with the tag, both survived, and the
// completion sidecar — which compiles the preamble as one file — reported REDECLARATION.
val normalized = segment.lines.joinToString(separator = "\n") { stripPreambleSupplierTag(it.trimEnd()) }.trim()
return normalized
}
private fun isImportSegment(segment: PreambleSegment): Boolean {
val firstMeaningfulLine = segment.lines
.asSequence()
.map(::stripPreambleBlockMarkers)
.firstOrNull { it.isNotBlank() }
?: return false
return isImportLine(firstMeaningfulLine)
}
private fun generatedPreambleGroup(key: String): String? {
val prefix = key.substringBefore(":", key)
return when {
prefix.startsWith("graphics-stack-auto") -> "stack-auto"
prefix.startsWith("stack-auto") -> "stack-auto"
prefix.startsWith("graphics-") -> "graphics"
prefix.startsWith("compute-") -> "compute"
prefix.startsWith("mesh-") -> "mesh"
prefix.startsWith("ray-") -> "ray"
else -> null
}
}
fun normalizePreambleText(preamble: String): String
Tidy a box's preamble text: drop duplicate declarations, hoist every import to the top, and remove
blank lines.
"Duplicate" is judged by a segment's key, and for a generated line the key is only the name in its
leading marker comment — so two lines both tagged graphics-ubo:params collapse to whichever appeared
first, whatever their bodies say. That is the right rule for de-duplicating a preamble that has
accumulated over several runs, and the wrong one if you were hoping the newer line would win; for
that, go through applyInsertToPreamble, where incoming segments do replace existing ones.
body — 259 lines, from line 3155
fun normalizePreambleText(preamble: String): String {
val merged = LinkedHashMap<String, PreambleSegment>()
splitPreambleSegments(preamble.lines()).forEach { segment ->
val key = preambleSegmentKey(segment)
if (key.isNotEmpty() && !merged.containsKey(key)) {
merged[key] = segment
}
}
val imports = merged.values.filter(::isImportSegment)
val nonImports = merged.values.filterNot(::isImportSegment)
return (imports + nonImports).flatMap { it.lines }.joinToString(separator = "\n")
}
private fun prefixed(prefix: String, code: String): String = "/*$prefix*/ $code"
private fun prefixedBlock(prefix: String, code: String): String =
"$preambleBlockStartMarker\n/*$prefix*/\n${code.trim()}\n$preambleBlockEndMarker"
private val kotlinIdentifierRegex = Regex("[A-Za-z_][A-Za-z0-9_]*")
private val kotlinHardKeywords = setOf(
"as", "break", "class", "continue", "do", "else", "false", "for", "fun", "if", "in", "interface",
"is", "null", "object", "package", "return", "super", "this", "throw", "true", "try", "typealias",
"typeof", "val", "var", "when", "while"
)
private fun kotlinMemberName(name: String): String =
if (kotlinIdentifierRegex.matches(name) && name !in kotlinHardKeywords) name else "`$name`"
private data class GeneratedShaderStructure(
val glslName: String,
val className: String,
val members: List<Introspected.Member>,
val byteSize: Int,
val signature: String,
val bufferReference: Boolean,
/** The type the box already imports under the block's own name, which [className] steps around. */
val wouldHide: String? = null,
)
private fun shaderMemberSignature(member: Introspected.Member): String =
buildString {
append(member.name)
append("@")
append(member.offset)
append(":")
append(member.arrayStride)
append(":")
append(member.physicalPointer)
append(":")
append(member.type.fold({ it.name }, { nested -> nested.joinToString(prefix = "{", postfix = "}", separator = ",", transform = ::shaderMemberSignature) }))
}
private fun shaderMembersSignature(members: List<Introspected.Member>): String =
members.joinToString(separator = ";", transform = ::shaderMemberSignature)
private fun sanitizeKotlinTypeName(name: String): String {
val parts = name.split(Regex("[^A-Za-z0-9_]+")).filter { it.isNotBlank() }
val joined = parts.joinToString("") { it.replaceFirstChar { c -> c.uppercase() } }.ifBlank { "ShaderStructure" }
val prefixed = if (joined.first().isDigit()) "Shader$joined" else joined
return prefixed
}
private fun sanitizeKotlinFunctionSuffix(name: String): String =
sanitizeKotlinTypeName(name)
private fun collectGeneratedShaderStructures(introspections: List<Introspection>): List<GeneratedShaderStructure> {
data class Candidate(
val name: String,
val members: List<Introspected.Member>,
val size: Int,
val bufferReference: Boolean
)
val candidates = mutableListOf<Candidate>()
introspections.forEach { i ->
i.structureTypes.forEach { s ->
candidates += Candidate(s.name, s.members, s.size, s.bufferReference)
}
// A stack.auto slot's numbers block is reached through its Structure, not a generated class.
i.ubos.filterNot { StackAutoNumbers.isNumbersBlock(it.name, it.members.map { m -> m.name }) }.forEach { ubo ->
candidates += Candidate(ubo.name, ubo.members, ubo.size, bufferReference = false)
}
i.ssbo.forEach { ssbo ->
candidates += Candidate(ssbo.name, ssbo.members, ssbo.size, bufferReference = false)
}
}
val out = mutableListOf<GeneratedShaderStructure>()
val signatureByClassName = mutableMapOf<String, String>()
val seenByNameAndSignature = mutableSetOf<Pair<String, String>>()
candidates.forEach { candidate ->
val signature = shaderMembersSignature(candidate.members)
if (!seenByNameAndSignature.add(candidate.name to signature)) return@forEach
// A block named after a type every box imports (`camera` -> `Camera`) would hide it: `Camera()`
// would build the block and `camera.at` stop resolving. Such a block's class is `CameraBlock`.
val plainName = sanitizeKotlinTypeName(candidate.name)
val wouldHide = afield.runtime.kotlin.introProvidedType(plainName)
val baseClassName = if (wouldHide != null) "${plainName}Block" else plainName
var className = baseClassName
var suffix = 2
while ((signatureByClassName[className] != null && signatureByClassName[className] != signature) ||
(className != plainName && className != baseClassName && afield.runtime.kotlin.introProvidedType(className) != null)) {
className = "$baseClassName$suffix"
suffix += 1
}
signatureByClassName[className] = signature
out += GeneratedShaderStructure(
glslName = candidate.name,
className = className,
members = candidate.members,
byteSize = maxOf(candidate.size, candidate.members.membersLayoutSizeInBytes()),
signature = signature,
bufferReference = candidate.bufferReference,
wouldHide = wouldHide,
)
}
return out
}
private fun buildShaderStructurePreamble(prefix: String, introspections: List<Introspection>): String {
val structures = collectGeneratedShaderStructures(introspections)
if (structures.isEmpty()) return ""
val bySignature = structures.associateBy { it.signature }
val emitted = mutableSetOf<String>()
val chunks = mutableListOf<String>()
fun nestedLayoutFor(member: Introspected.Member): GeneratedShaderStructure? {
if (member.physicalPointer) return null
val nested = member.type.fold({ null }, { it }) ?: return null
return bySignature[shaderMembersSignature(nested)]
}
lateinit var memberListExpression: (List<Introspected.Member>, String) -> String
fun memberTypeExpression(member: Introspected.Member): String {
if (member.physicalPointer) {
return "PrimitiveType.deviceptr.left()"
}
return member.type.fold(
{ "PrimitiveType.${it.name}.left()" },
{ nested ->
val nestedLayout = bySignature[shaderMembersSignature(nested)]
if (nestedLayout != null) "${nestedLayout.className}LayoutMembers().right()"
else memberListExpression(nested, " ").trim().removeSuffix(",").let { "$it.right()" }
}
)
}
memberListExpression = fun(members: List<Introspected.Member>, indent: String): String {
if (members.isEmpty()) return "emptyList()"
val nextIndent = "$indent "
return buildString {
appendLine("listOf(")
members.forEach { member ->
append(nextIndent)
append("Introspected.Member(")
append("\"${member.name}\", ")
append("${member.offset}, ")
append(memberTypeExpression(member))
append(", ")
append(member.arrayStride)
append(", ")
append(member.physicalPointer)
appendLine("),")
}
append("$indent)")
}
}
fun emit(structure: GeneratedShaderStructure) {
if (!emitted.add(structure.className)) return
structure.members.mapNotNull(::nestedLayoutFor).forEach(::emit)
val nestedProperties = structure.members.mapNotNull { member ->
nestedLayoutFor(member)?.let { nested ->
val memberName = kotlinMemberName(member.name)
""" val $memberName: ${nested.className}
get() = childStructure("${member.name}") { members, baseOffset, pathPrefix, root ->
${nested.className}(members, baseOffset, pathPrefix, root)
}"""
}
}
val primitiveProperties = structure.members.filter { nestedLayoutFor(it) == null }.map { member ->
val ty = kotlinBindingTypeName(member)
" var ${kotlinMemberName(member.name)} by Structure.FromStructure<$ty>()" +
shaderCommentSuffix(introspections, member.name, structure.glslName)
}
val helperSuffix = sanitizeKotlinFunctionSuffix(structure.className)
val layoutFunctionName = "${structure.className}LayoutMembers"
val byteSizeFunctionName = "${structure.className}ByteSize"
val body = buildString {
structure.wouldHide?.let {
appendLine("// GLSL block `${structure.glslName}` is ${structure.className} here: ${sanitizeKotlinTypeName(structure.glslName)} would hide $it.")
}
appendLine("class ${structure.className}(")
appendLine(" members: List<Introspected.Member> = $layoutFunctionName(),")
appendLine(" baseOffset: Int = 0,")
appendLine(" pathPrefix: String = \"\",")
appendLine(" root: Structure? = null")
appendLine(") : Structure(members, baseOffset, pathPrefix, root) {")
appendLine(" init { setMembers(members) }")
(nestedProperties + primitiveProperties).forEach {
appendLine(it)
}
appendLine()
appendLine("}")
appendLine()
appendLine("fun $byteSizeFunctionName(): Long = ${structure.byteSize}L")
appendLine()
appendLine("fun $layoutFunctionName(): List<Introspected.Member> =")
appendLine(" ${memberListExpression(structure.members, " ")}")
appendLine()
appendLine("fun HasBuffer.as$helperSuffix(): ${structure.className} =")
appendLine(" asStructure(${structure.className}(), $layoutFunctionName())")
appendLine()
appendLine("fun BufferParameters.as$helperSuffix(): ${structure.className} =")
appendLine(" buffer.as$helperSuffix()")
}
chunks += prefixedBlock("$prefix-structure-type:${structure.glslName}:${structure.className}", body)
}
structures.forEach(::emit)
return chunks.joinToString(separator = "\n") + "\n"
}
private fun mergePreambleSegments(existing: List<String>, incoming: List<String>): List<PreambleSegment> {
val merged = LinkedHashMap<String, PreambleSegment>()
val incomingSegments = splitPreambleSegments(incoming)
val replacementGroups = incomingSegments
.mapNotNull { generatedPreambleGroup(preambleSegmentKey(it)) }
.toSet()
splitPreambleSegments(existing).forEach { segment ->
val key = preambleSegmentKey(segment)
if (generatedPreambleGroup(key) in replacementGroups) return@forEach
if (key.isNotEmpty() && !merged.containsKey(key)) {
merged[key] = segment
}
}
incomingSegments.forEach { segment ->
val key = preambleSegmentKey(segment)
if (key.isNotEmpty()) {
// New insertions should win so reloads replace generated bindings in-place.
merged[key] = segment
}
}
return merged.values.toList()
}
fun applyInsertToPreamble(box: Box, e: Insert)
Fold an Insert's generated declarations into a box's preamble — how a shader reload gets its new typed bindings in front of the Kotlin compiler.
Writes to e.target if the Insert names one, otherwise to box. The merge is per-segment and
key-based, with two rules worth knowing: an incoming segment replaces an existing one with the
same key (so a reload updates a binding in place rather than appending a second copy), and an
incoming segment from a generated family — graphics-, compute-, mesh-, ray-, stack-auto —
causes every existing segment of that family to be dropped first, so bindings for a uniform you
just deleted from the shader disappear instead of lingering. import lines are hoisted above
everything else. An Insert with no preamble content is a no-op; the Insert's here text is not this
function's business.
What it does not do is re-run the box. New accessors are in the preamble but the box's Kotlin has to execute again before they mean anything at a call site.
body — 236 lines, from line 3431
fun applyInsertToPreamble(box: Box, e: Insert) {
val b = e.target ?: box
val incomingLines = buildList {
if (e.preamble.isNotBlank()) addAll(e.preamble.trim().lines())
if (e.preambleAfterImports.isNotBlank()) addAll(e.preambleAfterImports.trim().lines())
}.map { it.trimEnd() }.filter { it.isNotBlank() }
if (incomingLines.isEmpty()) return
val existingLines = b.preamble.lines().map { it.trimEnd() }.filter { it.isNotBlank() }
val merged = mergePreambleSegments(existingLines, incomingLines)
val imports = merged.filter(::isImportSegment)
val nonImports = merged.filterNot(::isImportSegment)
b.preamble = normalizePreambleText((imports + nonImports).flatMap { it.lines }.joinToString(separator = "\n"))
}
private fun clearShaderErrors(box: Box, vararg properties: String) {
if (!currentShaderReloadMayReport()) return
properties.forEach { property ->
box.displayError(null, property, -1)
}
}
private fun runShaderReloadAction(
boxes: Iterable<Box>,
reloadingMessage: String,
reloadedMessage: String,
reload: (Box) -> Boolean
) {
notify(reloadingMessage, "shader")
var ok = 0
var failed = 0
boxes.forEach { selected ->
val start = System.nanoTime()
val success = try {
reload(selected)
} catch (t: Throwable) {
lprintln(
"error,graphics",
"unexpected error reloading shader in '${selected.name}' (${selected.id}): ${t.message}"
)
selected.displayError(t.message ?: "shader reload failed", "code", -1)
false
}
LatencySpans.record("shader.reload", start, box = selected.id, detail = "success=$success")
if (success) ok++ else failed++
}
val summary = if (failed == 0) "$reloadedMessage ($ok)" else "$reloadedMessage ($ok ok, $failed failed)"
notify(summary, "shader")
}
private fun runShaderUpdateAction(
boxes: Iterable<Box>,
reloadingMessage: String,
update: suspend (Box, ShaderReloadKey, Long) -> Boolean
) {
val selected = boxes.toList()
if (selected.isEmpty()) return
notify(reloadingMessage, "shader")
selected.forEach { box ->
val (key, generation) = nextShaderGeneration(box, ShaderReloadType.GRAPHICS)
val start = System.nanoTime()
shaderReloadScope.launch {
val success = try {
update(box, key, generation)
} catch (t: Throwable) {
lprintln(
"error,graphics",
"unexpected error updating shader in '${box.name}' (${box.id}): ${t.message}"
)
if (isCurrentShaderGeneration(key, generation)) {
withContext(Runloop.runLoop.main) {
box.displayError(t.message ?: "shader update failed", "code", -1)
}
}
false
}
LatencySpans.record("shader.update", start, box = box.id, detail = "success=$success")
if (isCurrentShaderGeneration(key, generation)) {
withContext(Runloop.runLoop.main) {
notify(
if (success) "Updated shader in '${box.name}'" else "Shader update failed in '${box.name}'",
"shader"
)
}
}
}
}
}
private fun runComputeShaderUpdateAction(
boxes: Iterable<Box>,
reloadingMessage: String,
update: suspend (Box, ShaderReloadKey, Long) -> Boolean
) {
val selected = boxes.toList()
if (selected.isEmpty()) return
notify(reloadingMessage, "shader")
selected.forEach { box ->
val (key, generation) = nextShaderGeneration(box, ShaderReloadType.COMPUTE)
shaderReloadScope.launch {
val success = try {
update(box, key, generation)
} catch (t: Throwable) {
lprintln(
"error,graphics",
"unexpected error updating compute shader in '${box.name}' (${box.id}): ${t.message}"
)
if (isCurrentShaderGeneration(key, generation)) {
withContext(Runloop.runLoop.main) {
box.displayError(t.message ?: "compute shader update failed", "code", -1)
}
}
false
}
if (isCurrentShaderGeneration(key, generation)) {
withContext(Runloop.runLoop.main) {
notify(
if (success) "Updated compute shader in '${box.name}'" else "Compute shader update failed in '${box.name}'",
"shader"
)
}
}
}
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun Box.installGraphicsShaderReloadAction() {
this.actions += "moduleHere_reload" to Action("Reload Shader|alt r", "Reloads the graphics shader in this box") {
runBlocking {
GlobalScope.launch(Runloop.runLoop.main) {
runShaderReloadAction(
boxes = it,
reloadingMessage = "Reloading shader…",
reloadedMessage = "Reloaded shader"
) { selected ->
reloadShader(selected)
}
}
}
}
}
private fun Box.installGraphicsShaderUpdateAction() {
this.actions += "moduleHere_update" to Action(
"Update Shader|alt shift enter",
"Updates the graphics shader only if its reflected ABI is unchanged"
) {
runShaderUpdateAction(
boxes = it,
reloadingMessage = "Updating shader…"
) { selected, key, generation ->
updateShader(selected, key, generation)
}
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun Box.installComputeShaderReloadAction() {
this.actions += "computeModuleHere_reload" to Action(
"Reload Compute Shader|alt r",
"reloads the compute shader in this box"
) {
runBlocking {
GlobalScope.launch(Runloop.runLoop.main) {
runShaderReloadAction(
boxes = it,
reloadingMessage = "Reloading shader…",
reloadedMessage = "Reloaded shader"
) { selected ->
reloadComputeShader(selected)
}
}
}
}
}
private fun Box.installComputeShaderUpdateAction() {
this.actions += "computeModuleHere_update" to Action(
"Update Compute Shader|alt shift enter",
"updates the compute shader only if its reflected ABI is unchanged"
) {
runComputeShaderUpdateAction(
boxes = it,
reloadingMessage = "Updating compute shader…"
) { selected, key, generation ->
updateComputeShader(selected, key, generation)
}
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun Box.installMeshShaderReloadAction() {
this.actions += "meshModuleHere_reload" to Action(
"Reload Mesh Shader|alt r",
"reloads the mesh shader in this box"
) {
runBlocking {
GlobalScope.launch(Runloop.runLoop.main) {
runShaderReloadAction(
boxes = it,
reloadingMessage = "Reloading shader…",
reloadedMessage = "Reloaded shader"
) { selected ->
reloadMeshShader(selected)
}
}
}
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun Box.installRayTracingShaderReloadAction() {
this.actions += "rayTracingModuleHere_reload" to Action(
"Reload Ray Tracing Shader|alt r",
"reloads the ray tracing shader pipeline in this box"
) {
runBlocking {
GlobalScope.launch(Runloop.runLoop.main) {
runShaderReloadAction(
boxes = it,
reloadingMessage = "Reloading shader…",
reloadedMessage = "Reloaded shader"
) { selected ->
reloadRayTracingShader(selected)
}
}
}
}
}
Graphics (vertex/fragment) reload path
source — 95 lines, from line 3671
private suspend fun updateShader(box: Box, key: ShaderReloadKey, generation: Long): Boolean {
withContext(Runloop.runLoop.main) {
if (isCurrentShaderGeneration(key, generation)) clearShaderErrorsForBaseProperties(box, "vertex", "fragment")
}
val prepared = withShaderReloadErrorGate(key, generation) {
val vertexStage = prepareShaderStage(box, ShaderKind.VERTEX_SHADER, "vertex") ?: return false
val fragmentStage = prepareShaderStage(
box,
ShaderKind.FRAGMENT_SHADER,
"fragment",
priorStages = mapOf(ShaderKind.VERTEX_SHADER to vertexStage)
) ?: return false
val vertexCompile = compileStage(box, vertexStage)
val fragmentCompile = compileStage(box, fragmentStage)
if (vertexCompile.hadError || fragmentCompile.hadError) {
lprintln("error,graphics", "shader update compilation failed; keeping previous module.")
return false
}
val vi = vertexCompile.introspection ?: return false
val fi = fragmentCompile.introspection ?: return false
Pair(vertexStage to vi, fragmentStage to fi)
}
val (vertexPrepared, fragmentPrepared) = prepared
val (vertexStage, vi) = vertexPrepared
val (fragmentStage, fi) = fragmentPrepared
val pointer = box._shader ?: run {
reportShaderStageError(box, "code", "shader update", "No graphics shader is attached to this box.")
return false
}
// One box can own a module per eye. Update every live pointer as one transaction.
val pointers = (listOf(pointer) + stackAutoModulesOf(box).filterIsInstance<ModulePointer>().filter { it.current.enabled })
.distinct()
val currents = pointers.map { it to it.current }
for ((_, current) in currents) {
val abiCheck = graphicsShaderAbiCheck(
current.parameters.vertexIntrospection ?: current.vi,
current.parameters.fragmentIntrospection ?: current.fi,
vi,
fi
)
if (!abiCheck.compatible) {
val transition = runCatching {
graphicsShaderInterfaceTransition(
current.parameters.vertexIntrospection ?: current.vi,
current.parameters.fragmentIntrospection ?: current.fi,
vi,
fi
)
}.getOrNull()
reportAbiRefusalOnShader(box, "shader update", abiCheck.message, transition, "vertex", "fragment")
return false
}
}
val updates = mutableListOf<Pair<Module, Module.PipelineUpdate>>()
try {
for ((_, current) in currents) updates += current to current.buildPipelineUpdate(vi, fi)
} catch (t: Throwable) {
updates.forEach { (current, update) -> current.destroyUnusedPipelineUpdate(update) }
reportShaderStageError(
box,
"code",
"shader update pipeline creation",
t.message ?: "Unable to create graphics pipeline update.",
t
)
return false
}
return withContext(Runloop.runLoop.main) {
if (!isCurrentShaderGeneration(key, generation) || box._shader !== pointer ||
currents.any { (target, current) -> target.current !== current }) {
updates.forEach { (current, update) -> current.destroyUnusedPipelineUpdate(update) }
return@withContext false
}
assignPreparedShaderStage(box, vertexStage)
assignPreparedShaderStage(box, fragmentStage)
for ((current, update) in updates) {
current.parameters.vertex = vertexStage.glslSource
current.parameters.fragment = fragmentStage.glslSource
current.parameters.vertexSourceName = vertexStage.sourceName
current.parameters.fragmentSourceName = fragmentStage.sourceName
current.parameters.vertexIncludeResolver = shaderIncludeResolverFor(box, vertexStage.sourceProperty)
current.parameters.fragmentIncludeResolver = shaderIncludeResolverFor(box, fragmentStage.sourceProperty)
FramePresentationLog.mark("graphics shader update install begin box=${box.name} module=${current.parameters.name}")
current.installPipelineUpdate(update)
FramePresentationLog.mark("graphics shader update install end box=${box.name} module=${current.parameters.name}")
}
clearShaderErrorsForBaseProperties(box, "vertex", "fragment")
true
}
}
The prepared GLSL a reload is about to overwrite, for restorePreparedStageSources to put back if the
reload does not commit — so _vertex and friends keep describing the module that is actually running,
the same invariant the Update paths keep by assigning only after they install.
private fun preparedStageSources(box: Box, vararg baseProperties: String): Map<String, String?> =
baseProperties.associateWith { base ->
when (base) {
"vertex" -> box._vertex
"fragment" -> box._fragment
"compute" -> box._compute
"task" -> box._task
"mesh" -> box._mesh
"raygen" -> box._raygen
"miss" -> box._miss
"closesthit" -> box._closesthit
"anyhit" -> box._anyhit
"intersection" -> box._intersection
"callable" -> box._callable
else -> error("not a shader stage property: $base")
}
}
private fun restorePreparedStageSources(box: Box, sources: Map<String, String?>) {
sources.forEach { (base, source) ->
when (base) {
"vertex" -> box._vertex = source
"fragment" -> box._fragment = source
"compute" -> box._compute = source
"task" -> box._task = source
"mesh" -> box._mesh = source
"raygen" -> box._raygen = source
"miss" -> box._miss = source
"closesthit" -> box._closesthit = source
"anyhit" -> box._anyhit = source
"intersection" -> box._intersection = source
"callable" -> box._callable = source
}
}
}
Build the replacement module for a full reload and report whether it was installed — the step that makes a full reload a transaction rather than a sequence of side effects.
reload is a pointer's reload, handed an error handler. The pointers signal failure by returning
false: a module that throws while it builds (a pipeline that will not link, a resolver that throws or
comes up empty) catches that itself, reports it to the handler, and stays invalid, and the pointer keeps
its previous module. So the box's own handler — parameters' — still hears every error, and the first
one is kept to say, onto code, that the reload did not happen; a module adopted through
attachShader may have a handler that only logs, and a silent false is how this used to go unnoticed.
The pointer installs the handler it is given as the new module's; it is put back to the box's own afterwards so the next reload does not wrap it a second time.
private fun commitModuleReload(
box: Box,
logLabel: String,
parameters: Parameters,
reload: (onError: (String, Throwable?) -> Unit) -> Boolean
): Boolean {
val handler = parameters.errorHandler
var firstError: String? = null
val installed = reload { message, throwable ->
if (firstError == null) firstError = message
handler(message, throwable)
}
parameters.errorHandler = handler
if (!installed) {
reportShaderStageError(
box,
"code",
logLabel,
"${firstError ?: "The new module could not be built."}\nShader reload did not happen: the previous module is still running."
)
}
return installed
}
Say what a full reload that just committed did to the interface: the summary as a line note on the
shader properties the person edits (the properties of baseProperties, in whichever spelling — GLSL,
Slang, the shared vertex_fragment — the box uses), the values that could not be carried, and an
error marker on each line of code that uses a binding or member the reload removed, renamed or
retyped. See ShaderTransitionNotes.
private fun reportShaderReloadTransition(box: Box, report: ModuleReloadReport?, vararg baseProperties: String) {
val properties = baseProperties.flatMap(::shaderPropertyVariants).distinct()
runCatching { ShaderTransitionNotes.record(box, properties, report?.transition, report?.notes.orEmpty()) }
.onSuccess { summary -> if (summary.isNotBlank()) notify("${box.name}: $summary", "shader") }
.onFailure { lprintln("warning,graphics", "could not report the shader reload transition: ${it.message}") }
}
Put an Update Shader refusal where the interface changed: on the shader property, at the first line
that declares a binding the change touches (the first line when none is found), as well as on code.
transition says what changed; the message leads with its summary, then the signature diff.
private fun reportAbiRefusalOnShader(
box: Box,
logLabel: String,
message: String,
transition: ShaderInterfaceTransition?,
vararg baseProperties: String
) {
val summary = transition?.summary().orEmpty()
val text = if (summary.isBlank()) message
else "Update Shader keeps the interface, and this edit changes it: $summary. Use Reload Shader (alt-r).\n$message"
reportShaderStageError(box, "code", logLabel, text)
val names = transition?.changes.orEmpty().flatMap { listOf(it.name, it.oldName) + it.members.map { m -> m.name } }.distinct()
baseProperties.map { sourcePropertyForStage(box, it) }.distinct().forEach { property ->
val source = runCatching { shaderPropertyValue(box, property) }.getOrNull() ?: return@forEach
if (source.isBlank()) return@forEach
val line = source.lines().indexOfFirst { l -> names.any { Regex("""\b${Regex.escape(it)}\b""").containsMatchIn(l) } }
reportShaderStageError(box, property, logLabel, text, lineOverride = if (line >= 0) line + 1 else 1)
}
}
fun reloadShader(box: Box): Boolean
Recompile this box's vertex and fragment stages from its shader properties and swap the live
graphics module — the Reload Shader action (alt-r), as a function.
The full-rebuild path, as opposed to the ABI-preserving Update Shader (alt-shift-enter): it
re-preprocesses both stages, re-introspects them, rewrites the box's preamble with the resulting
typed bindings, and reloads the module with forceReresolve. That flag reaches only the texture
resolver — textures are re-resolved by name, falling back to the previous module's binding when the
resolver comes up empty. Use this when the shader's interface changed; use the update path when only
the arithmetic did.
The reload compares the old interface with the new (ShaderInterfaceTransition) and carries by it. A
uniform block that is kept, or renamed at the same set, binding and layout, keeps its buffer and the
values set on it; one that grew, shrank or changed a member's type gets a new, zeroed buffer, with the
old values re-applied where they still fit the new reflected types; an added block starts zeroed; a
block the module allocated and no longer uses is retired through the renderer's deferred queue. An SSBO
keeps its previous buffer while checkCompatibility passes. Buffers somebody supplied (found by name in
the context, or returned by a custom resolver) are resolved again as before and never freed here.
What changed is said where the person is looking: a one-line summary ("uniform block Light grew
16→32 bytes, buffer reallocated; push constant tint removed") as a line note on the shader property
and a notification, and an error marker on each line of code that uses a binding or member the
reload removed, renamed or retyped — that code was compiled against the old preamble, and re-running
the box compiles it against the new one. See ShaderTransitionNotes.
Must be called on the main runloop — it touches the Vulkan module directly. Errors are not thrown:
they are reported onto the offending shader property's line and the function returns false with the
previous module left running, so a broken edit does not black out the window. Stale errors from
a previous attempt are cleared on the way in and again on success.
It is a transaction: the new module is built first, and only once it is installed are the preamble
rewritten and the module it replaced retired (through the renderer's deferred queue). A module that
compiles but will not build — a pipeline that does not link, a resource that does not resolve — leaves
the old preamble, the old module and the error on code, and returns false; it used to rewrite the
preamble for an interface that never went live and return true.
One rough edge: a box with no graphics module at all throws a NullPointerException here, rather
than reporting "no shader attached" the way the update path politely does. Check box._shader != null
first if you cannot be sure.
body — 46 lines, from line 3925
fun reloadShader(box: Box): Boolean {
clearShaderErrorsForBaseProperties(box, "vertex", "fragment")
val vertexStage = prepareShaderStage(box, ShaderKind.VERTEX_SHADER, "vertex") ?: return false
val fragmentStage = prepareShaderStage(
box,
ShaderKind.FRAGMENT_SHADER,
"fragment",
priorStages = mapOf(ShaderKind.VERTEX_SHADER to vertexStage)
) ?: return false
val pointer = box._shader!!
val previousSources = preparedStageSources(box, "vertex", "fragment")
assignPreparedShaderStage(box, vertexStage)
assignPreparedShaderStage(box, fragmentStage)
val (vi, vErr) = compileStage(box, vertexStage)
val (fi, fErr) = compileStage(box, fragmentStage)
if (vErr || fErr) {
lprintln("error,graphics", "shader compilation failed; skipping module reload.")
restorePreparedStageSources(box, previousSources)
return false
}
// keep consistent with the Module path: build the Insert now, apply it once the module is live
val insert = insertForShader(vi!!, fi!!, "_moduleHere", listOf("()"), box)
val p = pointer.current.parameters.copy().apply {
vertex = box._vertex!!
fragment = box._fragment!!
vertexIntrospection = vi
fragmentIntrospection = fi
}
attachFieldProbe(box, p)
if (!commitModuleReload(box, "graphics shader reload", p) { onError ->
pointer.reload(p, forceReresolve = true, onError = onError)
}) {
restorePreparedStageSources(box, previousSources)
return false
}
applyInsertToPreamble(box, insert)
clearShaderErrorsForBaseProperties(box, "vertex", "fragment")
reportShaderReloadTransition(box, pointer.lastReloadReport, "vertex", "fragment")
return true
}
fun Box.attachShader(module: ModulePointer, overwriteProperties: Boolean = false): ModulePointer
Adopt a graphics module you built yourself into this box's hot-reload machinery: the box gains the
Reload Shader and Update Shader actions, its shader properties are seeded from the module's
source, and the module becomes box._shader.
The route in for code that constructs a Module directly rather than through moduleHere. Ownership
does not change — you get the same pointer back — but the box now drives it, and an immediate
reloadShader runs before this returns, so an unreachable #include in the adopted source surfaces
as a box error straight away.
Requires both stages to have non-blank source (it throws otherwise). By default the box's vertex /
fragment properties are only seeded if the box has none of its own, so an existing shader tab wins;
pass overwriteProperties = true to clobber it. Note what gets seeded is the module's already
expanded GLSL, so template slots and includes in the original are gone from the copy.
@param overwriteProperties replace the box's existing shader properties instead of leaving them alone
fun Box.attachShader(module: ModulePointer, overwriteProperties: Boolean = false): ModulePointer {
installGraphicsShaderReloadAction()
installGraphicsShaderUpdateAction()
val parameters = module.current.parameters
require(parameters.vertex.isNotBlank()) { "Attached graphics module must have a vertex shader source." }
require(parameters.fragment.isNotBlank()) { "Attached graphics module must have a fragment shader source." }
if (overwriteProperties || !hasShaderSource(this, "vertex")) setPropertyRaw("vertex", parameters.vertex)
if (overwriteProperties || !hasShaderSource(this, "fragment")) setPropertyRaw("fragment", parameters.fragment)
_vertex = parameters.vertex
_fragment = parameters.fragment
_shader = module
registerStackAutoShaderOwner(this, module)
reloadShader(this)
return module
}
Compute helpers (DRY the previously duplicated postImport construction)
private fun kotlinBindingTypeName(member: Introspected.Member): String =
if (member.physicalPointer) "afield.graphics.vulkan.buffers.HasBuffer"
else member.type.fold({ it.kotlinTypeName }, { "Any" })
Kotlin has no Int | UInt type. Only scalar uint push suppliers use Any; Structure validates
their evaluated values. Uniform/attribute types keep their existing UInt declarations.
A scalar float supplier is () -> Number, so time = { now - started } binds a Double as it
stands: Structure's write narrows it to the member's precision (StructureTypeCheck.coerce).
private fun kotlinPushBindingTypeName(member: Introspected.Member): String = when {
member.physicalPointer -> kotlinBindingTypeName(member)
member.type.fold({ it == PrimitiveType.uint }, { false }) -> "Any"
member.arrayStride == 0 && member.type.fold({ it == PrimitiveType.float || it == PrimitiveType.float16_t }, { false }) -> "Number"
else -> kotlinBindingTypeName(member)
}
The // … note the shader source carried at the end of a member's declaration line, formatted to
append to the generated preamble declaration for it — "" when the source said nothing.
A trailing line comment, rather than a KDoc block in front, for two reasons: the preamble is
line-oriented and keyed by the /*marker*/ that must stay at the START of the line
(preambleLineKey), and a shader author's comment is arbitrary text that could otherwise close the
KDoc it was pasted into. The comment is normalised to one line and capped, so a paragraph-length
note cannot stretch the preamble.
@param block the enclosing push-constant block, struct or UBO name, to disambiguate a member name that several blocks share
private fun shaderCommentSuffix(
introspections: List<Introspection>,
member: String,
block: String? = null
): String {
val comment = introspections.firstNotNullOfOrNull { it.memberComment(member, block) } ?: return ""
val oneLine = comment.replace(Regex("""\s+"""), " ").trim().take(240)
return if (oneLine.isEmpty()) "" else " // $oneLine"
}
internal fun buildComputeInsert(ci: Introspection, box: Box? = null): Insert
body — 67 lines, from line 4050
internal fun buildComputeInsert(ci: Introspection, box: Box? = null): Insert {
var postImport = ""
val commentSources = listOf(ci)
postImport += buildShaderStructurePreamble("compute", listOf(ci))
buildStackAutoShaderPreamble(box).takeIf { it.isNotBlank() }?.let {
postImport += it + "\n"
}
// Push constants
ci.pushConstants.flatMap { pc -> pc.members.map { pc.name to it } }.distinctBy { it.second.name }.forEach { (block, member) ->
val note = shaderCommentSuffix(commentSources, member.name, block)
postImport += prefixed(
"compute-pc-structure:${member.name}",
"var Structure.${member.name} by Structure.FromStructure<Any>()$note"
) + "\n"
val ty = kotlinPushBindingTypeName(member)
postImport += prefixed(
"compute-pc-binding:${member.name}",
"var ComputeMesh.${member.name} by ComputeMesh.BoundFromStructure<$ty>()$note"
) + "\n"
postImport += prefixed(
"compute-pc-binding-group:${member.name}",
"var ComputeMeshGroup.${member.name} by ComputeMeshGroup.BoundFromStructure<$ty>()$note"
) + "\n"
}
// UBOs
// A compute module's uniform block is reached from the compute module, as its SSBOs are. These were
// `ModulePointer` and `Mesh` accessors (the graphics module's), so `module.block` on a compute module
// did not resolve.
ci.ubos.distinctBy { it.name }.forEach { ubo ->
postImport += prefixed(
"compute-ubo:${ubo.name}",
"val ComputeModulePointer.${ubo.name} by ComputeMesh.FromUniform()"
) + "\n"
ubo.members.takeUnless { StackAutoNumbers.isNumbersBlock(ubo.name, ubo.members.map { it.name }) }.orEmpty().forEach { m ->
val ty = kotlinBindingTypeName(m)
postImport += prefixed(
"compute-ubo-member:${ubo.name}:${m.name}",
"var Structure.${m.name} by Structure.FromStructure<$ty>()" +
shaderCommentSuffix(commentSources, m.name, ubo.name)
) + "\n"
}
}
// SSBOs
ci.ssbo.distinctBy { it.name }.forEach { ssbo ->
postImport += prefixed(
"compute-ssbo:${ssbo.name}",
"val ComputeModulePointer.${ssbo.name} by ComputeMesh.FromUniform()"
) + "\n"
// Arrays / headers would need special handling if you decide to expose members
}
return Insert(
preamble = """
import afield.graphics.vulkan.buffers.*
import afield.document.context.*
import afield.graphics.render.*
import afield.graphics.scene.*
import afield.graphics.shader.*
import arrow.core.left
import arrow.core.right
""".trimIndent(),
preambleAfterImports = postImport
)
}
fun reloadComputeShader(box: Box): Boolean
Recompile this box's compute stage and swap the live compute module — the Reload Compute Shader
action (alt-r) as a function.
Same contract as reloadShader: main runloop only, errors reported onto the compute property rather
than thrown, false means the previous module is still the one running (with the previous preamble),
and on success the box's preamble is rewritten with the freshly introspected ComputeMesh bindings
and the replaced module retired. Uniform blocks and their values are carried across by the interface
diff, and the change is reported, exactly as there — before 2026-09-29 a compute reload carried no
uniform block at all, so every reload reset them. Likewise throws a
NullPointerException if the box has no compute module to reload.
body — 99 lines, from line 4130
fun reloadComputeShader(box: Box): Boolean {
clearShaderErrorsForBaseProperties(box, "compute")
val computeStage = prepareShaderStage(box, ShaderKind.COMPUTE_SHADER, "compute") ?: return false
val pointer = box._computeShader!!
val previousSources = preparedStageSources(box, "compute")
assignPreparedShaderStage(box, computeStage)
val (ci, cErr) = compileStage(box, computeStage)
if (cErr) {
lprintln("error,graphics", "compute shader compilation failed; skipping compute module reload.")
restorePreparedStageSources(box, previousSources)
return false
}
// Rebuild/import bindings like graphics path, applied once the module is live
val insert = buildComputeInsert(ci!!, box)
val p = pointer.current.parameters.copy().apply {
compute = box._compute!!
computeIntrospection = ci
}
attachFieldProbe(box, p)
if (!commitModuleReload(box, "compute shader reload", p) { onError -> pointer.reload(p, onError = onError) }) {
restorePreparedStageSources(box, previousSources)
return false
}
applyInsertToPreamble(box, insert)
clearShaderErrorsForBaseProperties(box, "compute")
reportShaderReloadTransition(box, pointer.lastReloadReport, "compute")
return true
}
private suspend fun updateComputeShader(box: Box, key: ShaderReloadKey, generation: Long): Boolean {
withContext(Runloop.runLoop.main) {
if (isCurrentShaderGeneration(key, generation)) clearShaderErrorsForBaseProperties(box, "compute")
}
val prepared = withShaderReloadErrorGate(key, generation) {
val computeStage = prepareShaderStage(box, ShaderKind.COMPUTE_SHADER, "compute") ?: return false
val computeCompile = compileStage(box, computeStage)
if (computeCompile.hadError) {
lprintln("error,graphics", "compute shader update compilation failed; keeping previous module.")
return false
}
val ci = computeCompile.introspection ?: return false
computeStage to ci
}
val (computeStage, ci) = prepared
val pointer = box._computeShader ?: run {
reportShaderStageError(box, "code", "compute shader update", "No compute shader is attached to this box.")
return false
}
val current = pointer.current
val abiCheck = computeShaderAbiCheck(
current.parameters.computeIntrospection ?: current.ci,
ci
)
if (!abiCheck.compatible) {
val transition = runCatching {
shaderInterfaceTransition(listOf("compute" to (current.parameters.computeIntrospection ?: current.ci)), listOf("compute" to ci))
}.getOrNull()
reportAbiRefusalOnShader(box, "compute shader update", abiCheck.message, transition, "compute")
return false
}
val pipelineUpdate = try {
current.buildPipelineUpdate(ci)
} catch (t: Throwable) {
reportShaderStageError(
box,
"code",
"compute shader update pipeline creation",
t.message ?: "Unable to create compute pipeline update.",
t
)
return false
}
return withContext(Runloop.runLoop.main) {
if (!isCurrentShaderGeneration(key, generation) || box._computeShader !== pointer || pointer.current !== current) {
current.destroyUnusedPipelineUpdate(pipelineUpdate)
return@withContext false
}
assignPreparedShaderStage(box, computeStage)
current.parameters.compute = computeStage.glslSource
current.parameters.computeSourceName = computeStage.sourceName
current.parameters.computeIncludeResolver = shaderIncludeResolverFor(box, computeStage.sourceProperty)
FramePresentationLog.mark("compute shader update install begin box=${box.name} module=${current.parameters.name}")
current.installPipelineUpdate(pipelineUpdate)
FramePresentationLog.mark("compute shader update install end box=${box.name} module=${current.parameters.name}")
clearShaderErrorsForBaseProperties(box, "compute")
true
}
}
fun Box.attachShader(module: ComputeModulePointer, overwriteProperties: Boolean = false): ComputeModulePointer
Adopt an externally built compute module into this box: install the reload and update actions, seed
the compute property from the module's source, take it as box._computeShader, and reload once.
See the graphics overload for the ownership and property-seeding rules; the only
difference is that just one stage is required.
fun Box.attachShader(module: ComputeModulePointer, overwriteProperties: Boolean = false): ComputeModulePointer {
installComputeShaderReloadAction()
installComputeShaderUpdateAction()
val parameters = module.current.parameters
require(parameters.compute.isNotBlank()) { "Attached compute module must have a compute shader source." }
if (overwriteProperties || !hasShaderSource(this, "compute")) setPropertyRaw("compute", parameters.compute)
_compute = parameters.compute
_computeShader = module
registerStackAutoShaderOwner(this, module)
reloadComputeShader(this)
return module
}
Mesh helpers (DRY the previously duplicated postImport construction)
source — 68 lines, from line 4257
private fun buildMeshInsert(ti: Introspection, mi: Introspection, fi: Introspection, box: Box? = null): Insert {
var postImport = ""
val commentSources = listOf(ti, mi, fi)
postImport += buildShaderStructurePreamble("mesh", listOf(ti, mi, fi))
buildStackAutoShaderPreamble(box).takeIf { it.isNotBlank() }?.let {
postImport += it + "\n"
}
// Push constants across task+mesh+fragment
(ti.pushConstants + mi.pushConstants + fi.pushConstants).flatMap { pc -> pc.members.map { pc.name to it } }
.distinctBy { it.second.name }.forEach { (block, member) ->
val note = shaderCommentSuffix(commentSources, member.name, block)
postImport += prefixed(
"mesh-pc-structure:${member.name}",
"var Structure.${member.name} by Structure.FromStructure<Any>()$note"
) + "\n"
val ty = kotlinPushBindingTypeName(member)
postImport += prefixed(
"mesh-pc-binding:${member.name}",
"var MeshMesh.${member.name} by MeshMesh.BoundFromStructure<$ty>()$note"
) + "\n"
postImport += prefixed(
"mesh-pc-binding-group:${member.name}",
"var MeshMeshGroup.${member.name} by MeshMeshGroup.BoundFromStructure<$ty>()$note"
) + "\n"
}
// UBOs across task+mesh+fragment
(ti.ubos + mi.ubos + fi.ubos).distinctBy { it.name }.forEach { ubo ->
postImport += prefixed(
"mesh-ubo:${ubo.name}",
"val MeshModulePointer.${ubo.name} by MeshMesh.FromUniform()"
) + "\n"
postImport += prefixed(
"mesh-ubo-mesh:${ubo.name}",
"var MeshMesh.${ubo.name} by MeshMesh.FromStructure<Structure>()"
) + "\n"
ubo.members.takeUnless { StackAutoNumbers.isNumbersBlock(ubo.name, ubo.members.map { it.name }) }.orEmpty().forEach { m ->
val ty = kotlinBindingTypeName(m)
postImport += prefixed(
"mesh-ubo-member:${ubo.name}:${m.name}",
"var Structure.${m.name} by Structure.FromStructure<$ty>()" +
shaderCommentSuffix(commentSources, m.name, ubo.name)
) + "\n"
}
}
// SSBOs across task+mesh+fragment
(ti.ssbo + mi.ssbo + fi.ssbo).distinctBy { it.name }.forEach { ssbo ->
postImport += prefixed(
"mesh-ssbo:${ssbo.name}",
"val MeshModulePointer.${ssbo.name} by MeshMesh.FromUniform()"
) + "\n"
}
return Insert(
preamble = """
import afield.graphics.vulkan.buffers.*
import afield.document.context.*
import afield.graphics.render.*
import afield.graphics.scene.*
import afield.graphics.shader.*
import arrow.core.left
import arrow.core.right
""".trimIndent(),
preambleAfterImports = postImport
)
}
fun reloadMeshShader(box: Box): Boolean
Recompile all three stages of this box's mesh-shader pipeline — task, then mesh, then fragment — and swap the live module.
Order matters and is why they are not independent: each stage is prepared with the previous ones
available, which is what makes {% copyPC %} able to copy the task stage's push-constant block into
the mesh stage and the mesh stage's into the fragment stage. All three must have source; a blank one
is an error, not an omission.
Main runloop only, errors reported onto the stage properties rather than thrown, false leaves the
previous module and preamble in place — the same transaction as reloadShader, with the same
interface diff, carry-over and report. There is no ABI-preserving Update action for mesh pipelines — only this
full reload — which is also why binding a stack.auto slot on a mesh module is heavier than on a
graphics one. Throws NullPointerException if the box has no mesh module.
body — 56 lines, from line 4341
fun reloadMeshShader(box: Box): Boolean {
clearShaderErrorsForBaseProperties(box, "task", "mesh", "fragment")
val taskStage = prepareShaderStage(box, ShaderKind.TASK_SHADER, "task") ?: return false
val meshStage = prepareShaderStage(
box,
ShaderKind.MESH_SHADER,
"mesh",
priorStages = mapOf(ShaderKind.TASK_SHADER to taskStage)
) ?: return false
val fragmentStage = prepareShaderStage(
box,
ShaderKind.FRAGMENT_SHADER,
"fragment",
priorStages = mapOf(
ShaderKind.TASK_SHADER to taskStage,
ShaderKind.MESH_SHADER to meshStage
)
) ?: return false
val pointer = box._meshShader!!
val previousSources = preparedStageSources(box, "task", "mesh", "fragment")
assignPreparedShaderStage(box, taskStage)
assignPreparedShaderStage(box, meshStage)
assignPreparedShaderStage(box, fragmentStage)
val (ti, tErr) = compileStage(box, taskStage)
val (mi, mErr) = compileStage(box, meshStage)
val (fi, fErr) = compileStage(box, fragmentStage)
if (tErr || mErr || fErr) {
lprintln("error,graphics", "mesh pipeline compilation failed; skipping mesh module reload.")
restorePreparedStageSources(box, previousSources)
return false
}
// Rebuild/import bindings like graphics path, applied once the module is live
val insert = buildMeshInsert(ti!!, mi!!, fi!!, box)
val p = pointer.current.parameters.copy().apply {
mesh = box._mesh!!
task = box._task!!
fragment = box._fragment!!
taskIntrospection = ti
meshIntrospection = mi
fragmentIntrospection = fi
}
attachFieldProbe(box, p)
if (!commitModuleReload(box, "mesh shader reload", p) { onError -> pointer.reload(p, onError = onError) }) {
restorePreparedStageSources(box, previousSources)
return false
}
applyInsertToPreamble(box, insert)
clearShaderErrorsForBaseProperties(box, "task", "mesh", "fragment")
reportShaderReloadTransition(box, pointer.lastReloadReport, "task", "mesh", "fragment")
return true
}
fun Box.attachShader(module: MeshModulePointer, overwriteProperties: Boolean = false): MeshModulePointer
Adopt an externally built mesh-shader module into this box, seeding the task, mesh and fragment
properties and reloading once.
All three stages must have source (it throws otherwise). Note this installs only the Reload Mesh
Shader action — there is no ABI-preserving update action for mesh pipelines, so alt-shift-enter
does nothing on such a box.
fun Box.attachShader(module: MeshModulePointer, overwriteProperties: Boolean = false): MeshModulePointer {
installMeshShaderReloadAction()
val parameters = module.current.parameters
require(parameters.task.isNotBlank()) { "Attached mesh module must have a task shader source." }
require(parameters.mesh.isNotBlank()) { "Attached mesh module must have a mesh shader source." }
require(parameters.fragment.isNotBlank()) { "Attached mesh module must have a fragment shader source." }
if (overwriteProperties || !hasShaderSource(this, "task")) setPropertyRaw("task", parameters.task)
if (overwriteProperties || !hasShaderSource(this, "mesh")) setPropertyRaw("mesh", parameters.mesh)
if (overwriteProperties || !hasShaderSource(this, "fragment")) setPropertyRaw("fragment", parameters.fragment)
_task = parameters.task
_mesh = parameters.mesh
_fragment = parameters.fragment
_meshShader = module
registerStackAutoShaderOwner(this, module)
reloadMeshShader(this)
return module
}
Ray tracing helpers
source — 92 lines, from line 4431
private data class RayTracingStageSpec(
val baseProperty: String,
val kind: ShaderKind,
val required: Boolean = false
)
private val rayTracingStageSpecs = listOf(
RayTracingStageSpec("raygen", ShaderKind.RAYGEN_SHADER, required = true),
RayTracingStageSpec("miss", ShaderKind.MISS_SHADER),
RayTracingStageSpec("closesthit", ShaderKind.CLOSEST_HIT_SHADER),
RayTracingStageSpec("anyhit", ShaderKind.ANY_HIT_SHADER),
RayTracingStageSpec("intersection", ShaderKind.INTERSECTION_SHADER),
RayTracingStageSpec("callable", ShaderKind.CALLABLE_SHADER)
)
private fun prepareRayTracingStages(box: Box): List<PreparedShaderStage>? {
val prepared = mutableListOf<PreparedShaderStage>()
val prior = mutableMapOf<ShaderKind, PreparedShaderStage>()
for (spec in rayTracingStageSpecs) {
if (!hasShaderSource(box, spec.baseProperty)) {
if (spec.required) {
reportShaderStageError(
box,
sourcePropertyForStage(box, spec.baseProperty),
"${spec.baseProperty} shader setup",
"No shader source found for property '${spec.baseProperty}'."
)
return null
}
continue
}
val stage = prepareShaderStage(box, spec.kind, spec.baseProperty, priorStages = prior) ?: return null
prepared += stage
prior[spec.kind] = stage
}
return prepared
}
private fun buildRayTracingInsert(introspections: List<Introspection>): Insert {
var postImport = ""
val commentSources = introspections
postImport += buildShaderStructurePreamble("ray", introspections)
introspections.flatMap { it.pushConstants }.flatMap { pc -> pc.members.map { pc.name to it } }
.distinctBy { it.second.name }.forEach { (block, member) ->
val note = shaderCommentSuffix(commentSources, member.name, block)
postImport += prefixed(
"ray-pc-structure:${member.name}",
"var Structure.${member.name} by Structure.FromStructure<Any>()$note"
) + "\n"
val ty = kotlinPushBindingTypeName(member)
postImport += prefixed(
"ray-pc-binding:${member.name}",
"var RayTracingDispatch.${member.name} by RayTracingDispatch.BoundFromStructure<$ty>()$note"
) + "\n"
}
introspections.flatMap { it.ubos }.distinctBy { it.name }.forEach { ubo ->
postImport += prefixed(
"ray-ubo:${ubo.name}",
"val RayTracingModulePointer.${ubo.name} by RayTracingFromUniform()"
) + "\n"
ubo.members.forEach { member ->
val ty = kotlinBindingTypeName(member)
postImport += prefixed(
"ray-ubo-member:${ubo.name}:${member.name}",
"var Structure.${member.name} by Structure.FromStructure<$ty>()" +
shaderCommentSuffix(commentSources, member.name, ubo.name)
) + "\n"
}
}
introspections.flatMap { it.ssbo }.distinctBy { it.name }.forEach { ssbo ->
postImport += prefixed(
"ray-ssbo:${ssbo.name}",
"val RayTracingModulePointer.${ssbo.name} by RayTracingFromUniform()"
) + "\n"
}
return Insert(
preamble = """
import afield.graphics.vulkan.buffers.*
import afield.document.context.*
import afield.graphics.render.*
import afield.graphics.scene.*
import afield.graphics.shader.*
import arrow.core.left
import arrow.core.right
""".trimIndent(),
preambleAfterImports = postImport
)
}
fun reloadRayTracingShader(box: Box): Boolean
Recompile whichever ray-tracing stages this box has and swap the live module.
raygen is required; the other five are included only if their property is non-blank, so deleting a
miss shader and reloading genuinely removes that stage from the pipeline. Stages are prepared in
declaration order with raygen available to the rest, which is what lets the hit and miss stages
{% copyPC %} the raygen push-constant block. rayTracingGroups is reset to null so the shader
binding table is rebuilt from the new stage set.
Main runloop only; errors are reported onto the stage properties and false leaves the previous
module and preamble in place — the same transaction as reloadShader, with the same interface diff,
carry-over and report. Like the mesh path there is no ABI-preserving update action. Throws
NullPointerException if the box has no ray-tracing module.
body — 45 lines, from line 4538
fun reloadRayTracingShader(box: Box): Boolean {
clearShaderErrorsForBaseProperties(box, "raygen", "miss", "closesthit", "anyhit", "intersection", "callable")
val stages = prepareRayTracingStages(box) ?: return false
val pointer = box._rayTracingShader!!
val previousSources = preparedStageSources(box, "raygen", "miss", "closesthit", "anyhit", "intersection", "callable")
stages.forEach { assignPreparedShaderStage(box, it) }
val compiled = stages.map { stage -> stage to compileStage(box, stage) }
if (compiled.any { it.second.hadError || it.second.introspection == null }) {
lprintln("error,graphics", "ray tracing pipeline compilation failed; skipping ray tracing module reload.")
restorePreparedStageSources(box, previousSources)
return false
}
val introspections = compiled.map { it.second.introspection!! }
val insert = buildRayTracingInsert(introspections)
val p = pointer.current.parameters.copy().apply {
raygen = box._raygen ?: ""
miss = box._miss ?: ""
closesthit = box._closesthit ?: ""
anyhit = box._anyhit ?: ""
intersection = box._intersection ?: ""
callable = box._callable ?: ""
raygenIntrospection = compiled.introspectionFor(ShaderKind.RAYGEN_SHADER)
missIntrospection = compiled.introspectionFor(ShaderKind.MISS_SHADER)
closesthitIntrospection = compiled.introspectionFor(ShaderKind.CLOSEST_HIT_SHADER)
anyhitIntrospection = compiled.introspectionFor(ShaderKind.ANY_HIT_SHADER)
intersectionIntrospection = compiled.introspectionFor(ShaderKind.INTERSECTION_SHADER)
callableIntrospection = compiled.introspectionFor(ShaderKind.CALLABLE_SHADER)
rayTracingGroups = null
}
attachFieldProbe(box, p)
if (!commitModuleReload(box, "ray tracing shader reload", p) { onError -> pointer.reload(p, onError = onError) }) {
restorePreparedStageSources(box, previousSources)
return false
}
applyInsertToPreamble(box, insert)
clearShaderErrorsForBaseProperties(box, "raygen", "miss", "closesthit", "anyhit", "intersection", "callable")
reportShaderReloadTransition(box, pointer.lastReloadReport, "raygen", "miss", "closesthit", "anyhit", "intersection", "callable")
return true
}
private fun List<Pair<PreparedShaderStage, CompileResult>>.introspectionFor(kind: ShaderKind): Introspection? =
firstOrNull { it.first.kind == kind }?.second?.introspection
fun Box.attachShader(module: RayTracingModulePointer, overwriteProperties: Boolean = false): RayTracingModulePointer
Adopt an externally built ray-tracing module into this box, seeding raygen (required) and whichever
optional stages the module actually has.
Blank optional stages are left unset rather than seeded as empty strings, so the box's property list
reflects the pipeline's real shape. Installs only the Reload Ray Tracing Shader action; unlike the
graphics and compute overloads it does not register _rayTracingShader via stack.auto ownership,
so {{ stack.auto.… }} slots in ray-tracing shaders have no module-pointer setter.
fun Box.attachShader(module: RayTracingModulePointer, overwriteProperties: Boolean = false): RayTracingModulePointer {
installRayTracingShaderReloadAction()
val parameters = module.current.parameters
require(parameters.raygen.isNotBlank()) { "Attached ray tracing module must have a raygen shader source." }
if (overwriteProperties || !hasShaderSource(this, "raygen")) setPropertyRaw("raygen", parameters.raygen)
if (overwriteProperties || !hasShaderSource(this, "miss")) parameters.miss.takeIf { it.isNotBlank() }?.let { setPropertyRaw("miss", it) }
if (overwriteProperties || !hasShaderSource(this, "closesthit")) parameters.closesthit.takeIf { it.isNotBlank() }?.let { setPropertyRaw("closesthit", it) }
if (overwriteProperties || !hasShaderSource(this, "anyhit")) parameters.anyhit.takeIf { it.isNotBlank() }?.let { setPropertyRaw("anyhit", it) }
if (overwriteProperties || !hasShaderSource(this, "intersection")) parameters.intersection.takeIf { it.isNotBlank() }?.let { setPropertyRaw("intersection", it) }
if (overwriteProperties || !hasShaderSource(this, "callable")) parameters.callable.takeIf { it.isNotBlank() }?.let { setPropertyRaw("callable", it) }
_raygen = parameters.raygen
_miss = parameters.miss.takeIf { it.isNotBlank() }
_closesthit = parameters.closesthit.takeIf { it.isNotBlank() }
_anyhit = parameters.anyhit.takeIf { it.isNotBlank() }
_intersection = parameters.intersection.takeIf { it.isNotBlank() }
_callable = parameters.callable.takeIf { it.isNotBlank() }
_rayTracingShader = module
reloadRayTracingShader(this)
return module
}
Graphics macro
fun Box.macro_moduleHere(args: List<String>): Insert?
Macro expansion for moduleHere(…) — the entry point MacroRegistry calls when it finds one in a
box's text. Delegates to Box.macro_moduleHereWithDefaultShaders with Field's stock vertex and
fragment shaders as the fallbacks.
@OptIn(DelicateCoroutinesApi::class)
fun Box.macro_moduleHere(args: List<String>): Insert? {
return macro_moduleHereWithDefaultShaders(args)
}
val defaultVertexShader
The vertex shader a box gets if moduleHere expands and the box has no vertex source of its own:
one vec3 pos input transformed by a push_constant block of two mat4s, p and m.
Worth knowing because it decides the box's initial Kotlin API — pos as the only mesh writer, and
p/m reachable as mesh.p / mesh.m. If your first run produced a mesh whose vertex attributes
do not match your real shader, this is what it matched instead: seed the shader in an earlier //%-
fragment.
val defaultVertexShader = """
#version 450
layout (location = 0) in vec3 pos;
layout(push_constant) uniform matrices {
mat4 p;
mat4 m;
} pc;
void main()
{
gl_Position = pc.p * pc.m * vec4(pos, 1);
}
""".trimIndent()
val defaultFragmentShader
The fragment shader a box gets if moduleHere expands with no fragment source of its own: a flat
warm orange, chosen so "it drew something" is unmistakable. Declares no uniforms, so it generates
no accessors.
val defaultFragmentShader = """
#version 450
layout(location = 0) out vec4 uFragColor;
void main()
{
uFragColor = vec4(1.0, 0.8, 0.4, 1.0);
}
""".trimIndent()
private const val SHADERTOY_VERTEX_TEMPLATE_KEY = "shadertoy.vertexPreamble"
private const val SHADERTOY_FRAGMENT_TEMPLATE_KEY = "shadertoy.fragmentPreamble"
private const val SHADERTOY_IMAGE_TEMPLATE_KEY = "shadertoy.image"
private val shadertoyVertexPreamble = """
#version 450
""".trimIndent()
internal val shadertoyFragmentPreamble
internal val shadertoyFragmentPreamble = """
#version 450
layout(location = 0) out vec4 uFragColor;
layout(set = 0, binding = 0) uniform ShadertoyUniforms {
vec3 iResolution;
float iTime;
float iTimeDelta;
float iFrameRate;
int iFrame;
vec4 iMouse;
vec4 iDate;
vec3 iChannelResolution0;
float iChannelTime0;
vec3 iChannelResolution1;
float iChannelTime1;
vec3 iChannelResolution2;
float iChannelTime2;
vec3 iChannelResolution3;
float iChannelTime3;
} st;
layout(set = 0, binding = 1) uniform sampler2D iChannel0;
layout(set = 0, binding = 2) uniform sampler2D iChannel1;
layout(set = 0, binding = 3) uniform sampler2D iChannel2;
layout(set = 0, binding = 4) uniform sampler2D iChannel3;
#define iResolution st.iResolution
#define iTime st.iTime
#define iTimeDelta st.iTimeDelta
#define iFrameRate st.iFrameRate
#define iFrame st.iFrame
#define iMouse st.iMouse
#define iDate st.iDate
#define iChannelTime float[4](st.iChannelTime0, st.iChannelTime1, st.iChannelTime2, st.iChannelTime3)
#define iChannelResolution vec3[4](st.iChannelResolution0, st.iChannelResolution1, st.iChannelResolution2, st.iChannelResolution3)
""".trimIndent()
internal val shadertoyImagePreamble
{{ shadertoy.image }}: the fragment preamble plus the main that calls the student's
mainImage, so a ShaderToy Image pass pastes in under one line.
That main does the two things that make ShaderToy code look the same here as in a browser.
fragCoord is y-up: Vulkan's gl_FragCoord starts at the top left, so it is flipped against
iResolution.y. And fragColor is a display value, as a WebGL canvas takes it, so it is decoded
from sRGB before it reaches Field's sRGB (or linear HDR) target, which would otherwise encode it a
second time and wash it out. Alpha is written as 1, as ShaderToy's canvas ignores it.
internal val shadertoyImagePreamble = shadertoyFragmentPreamble + "\n" + """
void mainImage(out vec4 fragColor, in vec2 fragCoord);
vec3 shadertoyDisplayToLinear(vec3 c) {
c = clamp(c, 0.0, 1.0);
return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c));
}
void main() {
vec4 color = vec4(0.0, 0.0, 0.0, 1.0);
mainImage(color, vec2(gl_FragCoord.x, iResolution.y - gl_FragCoord.y));
uFragColor = vec4(shadertoyDisplayToLinear(color.rgb), 1.0);
}
""".trimIndent()
private fun Box.ensureShadertoyTemplates() {
shaderTemplate.putIfAbsent(SHADERTOY_VERTEX_TEMPLATE_KEY, shadertoyVertexPreamble)
shaderTemplate.putIfAbsent(SHADERTOY_FRAGMENT_TEMPLATE_KEY, shadertoyFragmentPreamble)
shaderTemplate.putIfAbsent(SHADERTOY_IMAGE_TEMPLATE_KEY, shadertoyImagePreamble)
}
val shadertoyVertexShader
The vertex shader shadertoyHere seeds: no inputs at all, just a full-screen triangle synthesised
from gl_VertexIndex. That is why the shadertoy module needs a 3-vertex mesh and no vertex buffer.
It opens with a {{ shadertoy.vertexPreamble }} template slot, which shadertoyHere puts into the
box's Box.shaderTemplate map. Copy this text into a box that has never expanded shadertoyHere and
preprocessing fails on the unresolved slot.
val shadertoyVertexShader = """
{{ shadertoy.vertexPreamble }}
void main() {
vec2 pos = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2);
gl_Position = vec4(pos * 2.0 - 1.0, 0.0, 1.0);
}
""".trimIndent()
val shadertoyFragmentShader
The fragment shader shadertoyHere seeds: the {{ shadertoy.image }} slot, which brings in the
iResolution/iTime/iMouse/… uniform block, the #defines that make them look like shadertoy
globals and the main that calls mainImage, then a placeholder mainImage drawing a uv gradient
that pulses with iTime.
Replace the mainImage body (or paste a ShaderToy Image pass over everything below the slot). As
with shadertoyVertexShader, the slot only resolves on a box whose Box.shaderTemplate carries
the shadertoy preambles. A box that writes its own main uses {{ shadertoy.fragmentPreamble }}
instead, which is the uniforms and channels alone.
val shadertoyFragmentShader = """
{{ shadertoy.image }}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / max(iResolution.xy, vec2(1.0));
fragColor = vec4(uv, 0.5 + 0.5 * sin(iTime), 1.0);
}
""".trimIndent()
private data class ShadertoyTimeState(
val startedAtNs: Long = System.nanoTime(),
var lastFrameSampleNs: Long = startedAtNs,
var lastDeltaSeconds: Float = 1f / 60f,
var frames: Int = 0,
var lastFrameNs: Long = 0L,
var clickOrigin: Vector2Like? = null,
var lastPointer: Vector2Like? = null
)
private data class Vector2Like(val x: Float, val y: Float)
class ShadertoySurface
What a shadertoy module draws onto, for its iResolution and iMouse: size in pixels, and
pointer, the cursor in those pixels with y up (null when there is no cursor over it) plus
whether the left button is down.
class ShadertoySurface(
val size: () -> Pair<Int, Int>,
val pointer: () -> Pair<Pair<Float, Float>, Boolean>?
) {
companion object
companion object {
fun window(renderer: Renderer): ShadertoySurface
The renderer window: its framebuffer size, and the cursor scaled from window points.
fun window(renderer: Renderer): ShadertoySurface {
val window = renderer.window
return ShadertoySurface(
size = { window.width to window.height },
pointer = {
val points = window.size()
val c = window.cursor
val x = (c.x / points.w.coerceAtLeast(1.0) * window.width).toFloat()
val y = ((1.0 - c.y / points.h.coerceAtLeast(1.0)) * window.height).toFloat()
(x to y) to window.isMouseButtonPressed(GLFW.GLFW_MOUSE_BUTTON_LEFT)
}
)
}
}
}
The 1×1 black textures an unbound iChannel samples, as an unbound sampler does in WebGL.
private object ShadertoyTextures {
private val cache = java.util.WeakHashMap<Renderer, Map<String, LoadedTexture>>()
fun forRenderer(renderer: Renderer): Map<String, LoadedTexture> = cache.getOrPut(renderer) {
(0..3).associate { index ->
val name = "iChannel$index"
val data = MemoryUtil.memAlloc(4)
data.put(byteArrayOf(0, 0, 0, 255.toByte()))
data.flip()
name to LoadedTexture.fromRawRGBA(name, 1, 1, data, renderer, VK_FORMAT_R8G8B8A8_SRGB)
}
}
}
internal val shadertoyChannelsByModule
The channels each shadertoy module bound, for whoever re-binds its uniforms (ShadertoyCanvas).
internal val shadertoyChannelsByModule = java.util.WeakHashMap<ModulePointer, Array<ShadertoyChannel?>>()
internal fun shadertoyChannelIndex(name: String): Int?
0..3 for iChannel0..iChannel3, else null.
internal fun shadertoyChannelIndex(name: String): Int? =
name.removePrefix("iChannel").takeIf { name.startsWith("iChannel") && it.length == 1 }?.toIntOrNull()?.takeIf { it in 0..3 }
internal fun bindShadertoyUniforms
Bind the shadertoy uniform block of module to live values: time and frame counters, the date,
surface's size and pointer for iResolution and iMouse, and the sizes of channels for
iChannelResolution. Binding again (a ShadertoyCanvas does, to swap the window for itself)
replaces the earlier bindings and restarts the clock.
body — 81 lines, from line 4851
internal fun bindShadertoyUniforms(
module: ModulePointer,
surface: ShadertoySurface,
channels: Array<ShadertoyChannel?> = arrayOfNulls(4)
) {
val uniforms = module.current.liveUniformStructures["ShadertoyUniforms"] ?: return
val timeState = ShadertoyTimeState()
uniforms.bind("iResolution") {
val (w, h) = surface.size()
Vector3f(w.toFloat(), h.toFloat(), 1f)
}
uniforms.bind("iTime") {
((System.nanoTime() - timeState.startedAtNs) / 1_000_000_000.0).toFloat()
}
uniforms.bind("iTimeDelta") {
val now = System.nanoTime()
val delta = ((now - timeState.lastFrameSampleNs) / 1_000_000_000.0).toFloat().coerceAtLeast(0f)
timeState.lastFrameSampleNs = now
timeState.lastDeltaSeconds = if (delta > 0f) delta else timeState.lastDeltaSeconds
timeState.lastDeltaSeconds
}
uniforms.bind("iFrameRate") {
(1f / timeState.lastDeltaSeconds.coerceAtLeast(1f / 1000f))
}
uniforms.bind("iFrame") {
// Counted once per drawn frame: uniforms are read once a frame, but not only once.
val now = System.nanoTime()
if (now - timeState.lastFrameNs > 2_000_000L) {
if (timeState.lastFrameNs != 0L) timeState.frames++
timeState.lastFrameNs = now
}
timeState.frames
}
uniforms.bind("iMouse") {
// ShaderToy's convention: xy is the cursor while the button is down (and stays where it
// was released), zw the click position, negated once the button is up.
val pointer = surface.pointer()
val leftDown = pointer?.second == true
val cursor = pointer?.first?.let { Vector2Like(it.first, it.second) }
if (leftDown && cursor != null) {
if (timeState.clickOrigin == null) timeState.clickOrigin = cursor
timeState.lastPointer = cursor
}
val origin = timeState.clickOrigin
val at = timeState.lastPointer ?: Vector2Like(0f, 0f)
if (!leftDown) timeState.clickOrigin = null
val click = origin ?: timeState.lastPointer ?: Vector2Like(0f, 0f)
Vector4f(
at.x,
at.y,
if (leftDown) click.x else -click.x,
if (leftDown) click.y else -click.y
)
}
uniforms.bind("iDate") {
val now = LocalDateTime.now()
val seconds =
now.hour * 3600f + now.minute * 60f + now.second.toFloat() + now.nano / 1_000_000_000f
Vector4f(now.year.toFloat(), now.monthValue.toFloat(), now.dayOfMonth.toFloat(), seconds)
}
for (index in 0..3) {
uniforms.bind("iChannelResolution$index") {
val channel = channels[index]
if (channel == null) Vector3f(1f, 1f, 1f) else Vector3f(channel.width.toFloat(), channel.height.toFloat(), 1f)
}
uniforms.bind("iChannelTime$index") {
((System.nanoTime() - timeState.startedAtNs) / 1_000_000_000.0).toFloat()
}
}
}
private fun installShadertoyMesh(module: ModulePointer, renderer: Renderer, name: String = "shadertoy"): Mesh {
val existing = module.current.meshes[name]
if (existing != null) return existing
val mesh = Mesh(renderer, module)
mesh.resize(3, 0, module)
module.current.meshes[name] = mesh
return mesh
}
fun Box.macro_moduleHereWithDefaultShaders
The shared body of every graphics-module macro: seed the box's shader properties if it has none, preprocess and compile both stages at expansion time, and return the Insert that carries the generated typed bindings plus the rewritten call site.
Compiling during expansion is the whole point — the reflected interface is what the generated Mesh
writers, push-constant properties and structure classes are made of, so there is no way to produce
them without knowing what the shader declares. It is also why macro expansion is not side-effect
free: this installs the box's reload/update actions and can write its vertex/fragment properties.
If either stage fails to compile it still returns an Insert — but one carrying only the rewritten call
site, no bindings — so the box's Kotlin compiles and the shader error is what you see rather than a
cascade of unresolved references. The seeding is all-or-nothing across the two stages: a box with a
vertex shader but no fragment shader gets fragment filled in too.
@param args the macro's raw argument text, as captured from the call site
@param vertex fallback vertex source, used only if the box has no vertex source of its own
@param fragment fallback fragment source, likewise
@param resolvesToCall the runtime function the call site is rewritten to — _moduleHere normally,
_shadertoyHere for the shadertoy flavour
body — 49 lines, from line 4954
fun Box.macro_moduleHereWithDefaultShaders(
args: List<String>,
vertex: String = defaultVertexShader,
fragment: String = defaultFragmentShader,
resolvesToCall: String = "_moduleHere"
): Insert? {
installGraphicsShaderReloadAction()
installGraphicsShaderUpdateAction()
if (!hasShaderSource(this, "fragment") || !hasShaderSource(this, "vertex")) {
this.setPropertyRaw(
"fragment",
shaderPropertyValue(this, "fragment")?.takeIf { it.isNotBlank() } ?: fragment
)
this.setPropertyRaw(
"vertex",
shaderPropertyValue(this, "vertex")?.takeIf { it.isNotBlank() } ?: vertex
)
}
// Prepare code & introspect
clearShaderErrorsForBaseProperties(this, "vertex", "fragment")
val vertexStage = prepareShaderStage(this, ShaderKind.VERTEX_SHADER, "vertex")
val fragmentStage = vertexStage?.let {
prepareShaderStage(
this,
ShaderKind.FRAGMENT_SHADER,
"fragment",
priorStages = mapOf(ShaderKind.VERTEX_SHADER to it)
)
}
val vi = vertexStage?.let {
assignPreparedShaderStage(this, it)
compileStage(this, it).introspection
}
val fi = fragmentStage?.let {
assignPreparedShaderStage(this, it)
compileStage(this, it).introspection
}
if (vi == null || fi == null) {
var here = args[0]
if (args.size == 2) here += args[1]
return Insert(here = "$resolvesToCall$here")
} else {
clearShaderErrorsForBaseProperties(this, "vertex", "fragment")
return insertForShader(vi, fi, resolvesToCall, args, this)
}
}
fun Box.macro_shadertoyHere(args: List<String>): Insert?
Macro expansion for shadertoyHere(…): register the shadertoy template preambles on the box, then run
the ordinary graphics expansion with the shadertoy vertex/fragment shaders as fallbacks and
_shadertoyHere as the runtime call.
The template registration has to happen first, because the seeded shaders reference
{{ shadertoy.fragmentPreamble }} and preprocessing would otherwise fail on the unresolved slot.
fun Box.macro_shadertoyHere(args: List<String>): Insert? {
ensureShadertoyTemplates()
return macro_moduleHereWithDefaultShaders(
args,
vertex = shadertoyVertexShader,
fragment = shadertoyFragmentShader,
resolvesToCall = "_shadertoyHere"
)
}
Graphics helpers for Insert
source — 50 lines, from line 5026
private fun buildStackAutoShaderPreamble(box: Box?): String {
val names = box?.stackAutoShaderSources
?.keys
?.filter { stackAutoShaderNameRegex.matches(it) }
?.sorted()
?: return ""
/*
* How to drive stack.auto templates:
*
* 1. Put a StackSL auto slot in GLSL with `{{ stack.auto.foo }}`,
* `{{ stack.auto.foo:word }}`, `{{ stack.auto.foo@samplerName }}`, or
* `{{ stack.auto.foo:word@samplerName }}`. Template expansion emits a GLSL function
* named `foo` exactly once per expanded shader stage, so later GLSL can
* call `foo(vec4(...))`. The optional `:word` forces a StackSL definition
* in `module.foo` to be used as the generated function body. The optional
* `@samplerName` makes sample words read that GLSL sampler instead of `source`.
* 2. If no Kotlin code has bound `foo`, the emitted function is the stable
* fallback `vec4 foo(vec4 foo_in) { return vec4(0.0); }`. The argument
* name deliberately avoids `input`, which is reserved in GLSL 450.
* 3. During shader macro/reload preamble generation, every discovered
* `stack.auto.*` name becomes a Kotlin extension property on shader module pointers:
*
* module.foo = "p length ]2s2.0"
*
* The setter first compiles the StackSL string. Failed compiles leave the
* previous binding and current shader pipeline untouched.
* 4. Successful setter calls queue a shader-family-specific update. Graphics
* and compute use the ABI-preserving background path; mesh currently uses
* the mesh reload path. Rapid writes are coalesced into one pending follow-up.
*/
return names.joinToString(separator = "\n") { name ->
prefixedBlock(
"stack-auto:$name",
"""
var ModulePointer.${kotlinMemberName(name)}: String
get() = afield.graphics.shader.stackAutoShaderSource(this, "$name")
set(value) { afield.graphics.shader.stackAutoShaderSource(this, "$name", value) }
var ComputeModulePointer.${kotlinMemberName(name)}: String
get() = afield.graphics.shader.stackAutoShaderSource(this, "$name")
set(value) { afield.graphics.shader.stackAutoShaderSource(this, "$name", value) }
var MeshModulePointer.${kotlinMemberName(name)}: String
get() = afield.graphics.shader.stackAutoShaderSource(this, "$name")
set(value) { afield.graphics.shader.stackAutoShaderSource(this, "$name", value) }
""".trimIndent()
)
}
}
fun insertForShader
Turn a compiled vertex+fragment pair into the Insert that gives a box its typed graphics API: one
val Mesh.<name> by FromInput() per vertex attribute, the m.set { } writer map, a generated Kotlin
class per GLSL struct/UBO/SSBO layout, stack.auto string properties, and Mesh/MeshGroup
accessors for every push-constant member. UBO members get less than that: the block
itself becomes a val ModulePointer.<name> by FromUniform() and each member a
var Structure.<name>, with no Mesh/MeshGroup accessor, so a UBO member is written through the
uniform rather than per-mesh. (The compute and mesh-shader inserts do emit Mesh.<uboName>; this
graphics path does not.)
Shared by macro expansion and reloadShader so both produce byte-identical preamble segments — which
is what lets applyInsertToPreamble recognise and replace them in place instead of piling up copies.
Members with the same name across the two stages are emitted once. Push-constant members also get an
untyped Structure.<name> accessor, so the m.push["name"] form works without any of this.
@param resolvesToCall the runtime function the call site becomes — _moduleHere or _shadertoyHere
@param args the call site's argument text, as PSI siblings after the callee; the first is appended to
the call, and a second or third (a trailing lambda) is appended after it — args[1] of three, which is
only the whitespace before a lambda, is dropped
@param box the owning box, used only to discover its stack.auto slot names; null skips those
body — 82 lines, from line 5098
fun insertForShader(
vi: Introspection,
fi: Introspection,
resolvesToCall: String = "_moduleHere",
args: List<String>,
box: Box? = null
): Insert {
var postImport = ""
val commentSources = listOf(vi, fi)
vi.inputs.forEach {
postImport += prefixed(
"graphics-input:${it.name}",
"val Mesh.${it.name} by FromInput()"
) + "\n"
}
postImport += writeSuperMap(vi.inputs) + "\n"
postImport += buildShaderStructurePreamble("graphics", listOf(vi, fi))
buildStackAutoShaderPreamble(box).takeIf { it.isNotBlank() }?.let {
postImport += it + "\n"
}
(vi.pushConstants + fi.pushConstants).flatMap { pc -> pc.members.map { pc.name to it } }
.distinctBy { it.second.name }.forEach { (block, member) ->
val note = shaderCommentSuffix(commentSources, member.name, block)
postImport += prefixed(
"graphics-pc-structure:${member.name}",
"var Structure.${member.name} by Structure.FromStructure<Any>()$note"
) + "\n"
val ty = kotlinPushBindingTypeName(member)
postImport += prefixed(
"graphics-pc-binding:${member.name}",
"var Mesh.${member.name} by Mesh.BoundFromStructure<$ty>()$note"
) + "\n"
postImport += prefixed(
"graphics-pc-binding-group:${member.name}",
"var MeshGroup.${member.name} by MeshGroup.BoundFromStructure<$ty>()$note"
) + "\n"
}
(fi.ubos + vi.ubos).distinctBy { it.name }.forEach {
postImport += prefixed(
"graphics-ubo:${it.name}",
"val ModulePointer.${it.name} by FromUniform()"
) + "\n"
it.members.takeUnless { _ -> StackAutoNumbers.isNumbersBlock(it.name, it.members.map { m -> m.name }) }.orEmpty().forEach { m ->
val ty = kotlinBindingTypeName(m)
postImport += prefixed(
"graphics-ubo-member:${it.name}:${m.name}",
"var Structure.${m.name} by Structure.FromStructure<$ty>()" +
shaderCommentSuffix(commentSources, m.name, it.name)
) + "\n"
}
}
(fi.ssbo + vi.ssbo).distinctBy { it.name }.forEach {
postImport += prefixed(
"graphics-ssbo:${it.name}",
"val ModulePointer.${it.name} by FromUniform()"
) + "\n"
}
var here = "$resolvesToCall${args[0]}"
if (args.size == 2) here += args[1]
if (args.size == 3) here += args[2]
return Insert(
preamble = """
import afield.graphics.vulkan.buffers.*
import afield.document.context.*
import afield.graphics.render.*
import afield.graphics.scene.*
import afield.graphics.shader.*
import arrow.core.left
import arrow.core.right
import java.nio.*
""".trimIndent(),
preambleAfterImports = postImport,
here = here
)
}
Expanded graphics module creation
fun Box._moduleHere(p: Parameters.() -> Unit): ModulePointer {
The runtime graphics-module builder for the trailing-lambda call site, moduleHere { … } after
expansion. Resolves the Renderer from the ambient context — which means this box must be a
descendant of the box that owns the window, or fromContext() finds nothing.
fun Box._moduleHere(p: Parameters.() -> Unit): ModulePointer {
val parameters = parameters(renderer = fromContext(), p)
return _moduleHere(parameters).also { attachDeclaredMeshes(it, parameters) }
}
Paint an error on box for each graphics stage whose shader the caller put in p and Box._moduleHere
is about to replace with the box's own. moduleHere { vertex = … } compiles and does nothing, so without
this the first sign of it is a mesh writer or push constant that the shader actually running lacks.
Text equal to the box's property, raw or prepared, is not reported: nothing is lost by dropping it.
private fun reportShadersAssignedInModuleBlock(box: Box, p: Parameters) {
val dropped = listOf(
Triple("vertex", p.vertex, box._vertex),
Triple("fragment", p.fragment, box._fragment)
).filter { (stage, assigned, prepared) ->
assigned.isNotBlank() && assigned != prepared && assigned != shaderPropertyValue(box, stage)
}.map { it.first }
if (dropped.isEmpty()) return
val stages = dropped.joinToString(" and ") { "`$it`" }
val noun = if (dropped.size == 1) "property" else "properties"
val shaders = if (dropped.size == 1) "shader" else "shaders"
box.displayError(
"moduleHere ignored the $stages $shaders assigned inside its block. The module is built from this " +
"box's $stages $noun, which moduleHere seeds with a default shader when empty. Put the GLSL in " +
"the $noun (or set it with `once.${dropped.first()} { … }` in an earlier `//%-` fragment), or " +
"build the module directly: `Module(parameters(renderer) { … }).pointer()`.",
"code"
)
}
fun Box._moduleHere(p: Parameters = parameters {}): ModulePointer
Build this box's graphics module and store it as box._shader — the function every expanded
moduleHere call actually ends up in.
Takes its GLSL from the prepared _vertex / _fragment properties, overwriting whatever the caller
put in p, which is the mechanism behind "assigning vertex inside the lambda does nothing". A shader
that was assigned there and differs from the box's own is reported as an error on the box, because the
module otherwise runs the box's shader (often the seeded default) with no hint that the assignment was
dropped. Throws a NullPointerException if no prepare pass has run — normally impossible from an
expanded macro, easy to hit when calling this by hand.
Also installed here: the box's include resolvers and source names (so shader errors point at box
lines), a texture resolver that finds samplers by matching their GLSL name against this box's
properties, the field-probe and field-stats SSBO resolvers, and an error handler that reports
compilation and validation messages onto the box's code property.
Two things to be aware of. The shared-Slang (vertex_fragment) path re-prepares and re-compiles both
stages here, so a box on that route pays for compilation twice — once at expansion, once now. And it
sets globalAsyncDebugHandler, which is exactly as global as it sounds: the most recently built module
wins, so asynchronous Vulkan debug messages are reported onto that box regardless of which one
provoked them.
body — 59 lines, from line 5242
fun Box._moduleHere(p: Parameters = parameters {}): ModulePointer {
reportShadersAssignedInModuleBlock(this, p)
val vertexSourceProperty = sourcePropertyForStage(this, "vertex")
val fragmentSourceProperty = sourcePropertyForStage(this, "fragment")
if (!shaderPropertyValue(this, sharedVertexFragmentProperty).isNullOrBlank()) {
clearShaderErrorsForBaseProperties(this, "vertex", "fragment")
val vertexStage = prepareShaderStage(this, ShaderKind.VERTEX_SHADER, "vertex")
?: throw IllegalStateException("Unable to prepare shared vertex shader")
val fragmentStage = prepareShaderStage(
this,
ShaderKind.FRAGMENT_SHADER,
"fragment",
priorStages = mapOf(ShaderKind.VERTEX_SHADER to vertexStage)
)
?: throw IllegalStateException("Unable to prepare shared fragment shader")
assignPreparedShaderStage(this, vertexStage)
assignPreparedShaderStage(this, fragmentStage)
val vertexCompile = compileStage(this, vertexStage)
val fragmentCompile = compileStage(this, fragmentStage)
val vi = vertexCompile.introspection
?: throw IllegalStateException("Unable to compile shared vertex shader")
val fi = fragmentCompile.introspection
?: throw IllegalStateException("Unable to compile shared fragment shader")
p.vertex = this._vertex!!
p.fragment = this._fragment!!
p.vertexIntrospection = vi
p.fragmentIntrospection = fi
} else {
p.vertex = this._vertex!!
p.fragment = this._fragment!!
}
p.vertexSourceName = boxShaderSourceName(this, vertexSourceProperty)
p.fragmentSourceName = boxShaderSourceName(this, fragmentSourceProperty)
p.vertexIncludeResolver = shaderIncludeResolverFor(this, vertexSourceProperty)
p.fragmentIncludeResolver = shaderIncludeResolverFor(this, fragmentSourceProperty)
if (p.name == "unnamed module") p.name = "module in box '${name}' (${id})"
val previousResolver = p.textureResolver
p.textureResolver = textureResolverFor(previousResolver)
globalAsyncDebugHandler = { message ->
this.displayError(message, "code", numberFromErrorMessage(message) ?: 0)
}
p.errorHandler = { message, throwable ->
this.displayError(message, "code", numberFromErrorMessage(message) ?: 0)
throwable?.let { lprintln("error,graphics", it.stackTraceToString()) }
}
attachFieldProbe(this, p)
this._shader = Module(p).pointer()
registerStackAutoShaderOwner(this, this._shader!!)
return this._shader!!
}
fun Box._shadertoyHere(p: Parameters.() -> Unit): ModulePointer {
The runtime shadertoy-module builder for the trailing-lambda call site. Resolves the Renderer from
the ambient context, so the box must be a descendant of its renderer box.
fun Box._shadertoyHere(p: Parameters.() -> Unit): ModulePointer {
return _shadertoyHere(parameters(renderer = fromContext(), p))
}
fun Box._shadertoyHere(p: Parameters = parameters {}): ModulePointer
Build a shadertoy-shaped module: an ordinary Box._moduleHere with depth testing off, the shadertoy
uniform block bound to live values, the four iChannels bound, and a 3-vertex mesh named
"shadertoy" already attached so the box draws without you building any geometry.
An iChannelN is whatever textures("iChannelN" to t) in the block gives it, else anything named
iChannelN that Field's by-name search finds on the box or its parents, else a 1×1 black texture.
A bound texture is sampled the way ShaderToy samples one: through a ShadertoyChannel, a copy of
it turned bottom-up and holding display (sRGB-encoded) values, so texture(iChannel0, fragCoord /
iResolution.xy) is the right way up. iChannelResolution reports its size.
iResolution and iMouse come from the renderer window (in framebuffer pixels, y up); a
ShadertoyCanvas the module is added to replaces them with its own. iFrame counts drawn frames,
and iTime starts when the module is built, so re-running the box restarts the clock.
fun Box._shadertoyHere(p: Parameters = parameters {}): ModulePointer {
val renderer: Renderer = p.renderer
val dummyTextures = ShadertoyTextures.forRenderer(renderer)
val requested = p.textureResolver
val channels = arrayOfNulls<ShadertoyChannel>(4)
val box = this
p.depthTest = false
p.textureResolver = { textureSpec ->
val index = shadertoyChannelIndex(textureSpec.name)
if (index == null) requested(textureSpec)
else {
Box.context.set(box)
val found = requested(textureSpec) ?: box.findTextureByNameOrNull(textureSpec.name)
if (found == null) {
channels[index] = null
fromViews(listOf(dummyTextures.getValue(textureSpec.name)), textureSpec.name)
} else {
val channel = ShadertoyChannel.of(renderer, found)
channels[index] = channel
fromViews(listOf(channel.target), textureSpec.name, channel.sampler)
}
}
}
val module = _moduleHere(p)
bindShadertoyUniforms(module, ShadertoySurface.window(renderer), channels)
synchronized(shadertoyChannelsByModule) { shadertoyChannelsByModule[module] = channels }
installShadertoyMesh(module, renderer)
return module
}
var Box.customTextureResolver by Inheritance<((Box, String) -> HasImageViewArray?)?>()
A hook for deciding yourself what a shader's sampler2D someName should be bound to, consulted before
Field's own by-name search of the box's properties.
Called with the box and the sampler's GLSL name; return null to fall through to the normal search
(which tries HasImageViewArray, HasImageView, ImageView, arrays of those, LoadedTexture and
RenderToTexture, in that order, and throws if none matches). Inherited, so a renderer box can supply
one resolver for a whole family of shader boxes.
Invoked from the texture resolver during module construction and resolution, on the graphics thread.
Note that the surrounding resolver sets the thread-local Box.context to the box and never restores it
— a todo in the source, and something to be aware of if your lambda cares about that context.
fun fromViews
Package a fixed list of image views as the (provider, sampler) pair a texture resolver returns — the
usual way to answer "bind these images to that sampler".
The list is captured: later changes to the underlying object's view array are not seen, which makes
this the wrong overload for something like a RenderToTexture that can swap its views (use the
HasImageViewArray overload for that). The returned provider also forwards prepareSampledImagesFor
to any view that wants it, so render-to-texture layout transitions still happen.
The sampler is the first of: sampler if you gave one, the default sampler of the first view that
has one — the list is scanned, so with a mixed list a later view can supply it — or a new
mip-aware TextureSampler built against fromContext()'s renderer, whose mip count comes from
views.first() regardless of which view supplied the sampler. That last case needs a live graphics
context on this thread. Throws if views is empty.
fun fromViews(
views: List<HasImageView>,
name: String = "",
sampler: TextureSampler? = null
): Pair<HasImageViewArray, TextureSampler> {
require(views.isNotEmpty()) { "No image views found for texture $name" }
val provider = object : HasImageViewArray, PreparesSampledImages {
override val imageViewArray = views
override fun prepareSampledImagesFor(command: afield.graphics.vulkan.device.CommandBuffer) {
views.forEach { view ->
(view as? PreparesSampledImages)?.prepareSampledImagesFor(command)
}
}
}
val defaultSampler = views.asSequence()
.mapNotNull { (it as? HasDefaultTextureSampler)?.defaultSampler }
.firstOrNull()
val sampler0 = sampler ?: defaultSampler ?: TextureSampler(
renderer = fromContext(),
sampledTextureMipLevels(views.first()),
true
)
return provider to sampler0
}
private fun sampledTextureMipLevels(view: HasImageView): Int {
return (view as? RenderToTexture)?.mipLevels
?: view.imageView.imageViewData.mipLevels
}
fun fromViews
Package a live view provider as the (provider, sampler) pair a texture resolver returns — the
overload to prefer when the thing you are binding can change its views underneath you.
Unlike the List<HasImageView> overload this does not snapshot: the wrapper re-reads
provider.imageViewArray every time it is asked, so a RenderToTexture that reallocates keeps
resolving to its current images. Sampler selection has the same shape as the List overload but a
different middle step: only the provider's own default sampler is consulted, never an individual
view's, so a provider that is not HasDefaultTextureSampler goes straight to building a new
TextureSampler — and therefore needs a live graphics context — even when its views carry default
samplers of their own. Mip levels are taken from the provider when it is a RenderToTexture.
Throws if the provider is currently empty.
body — 41 lines, from line 5429
fun fromViews(
provider: HasImageViewArray,
name: String = "",
sampler: TextureSampler? = null
): Pair<HasImageViewArray, TextureSampler> {
val providerSampler = (provider as? HasDefaultTextureSampler)?.defaultSampler
val views = provider.imageViewArray
require(views.isNotEmpty()) { "No image views found for texture $name" }
val sampler0 = sampler ?: providerSampler ?: TextureSampler(
renderer = fromContext(),
sampledTextureMipLevels(provider, views.first()),
true
)
val forwardingProvider = object : HasImageViewArray, PreparesSampledImages {
override val imageViewArray: List<HasImageView>
get() = provider.imageViewArray
override fun prepareSampledImagesFor(command: afield.graphics.vulkan.device.CommandBuffer) {
(provider as? PreparesSampledImages)?.prepareSampledImagesFor(command)
provider.imageViewArray.forEach { view ->
(view as? PreparesSampledImages)?.prepareSampledImagesFor(command)
}
}
}
return forwardingProvider to sampler0
}
private fun sampledTextureMipLevels(provider: HasImageViewArray, firstView: HasImageView): Int {
return (provider as? RenderToTexture)?.mipLevels
?: sampledTextureMipLevels(firstView)
}
private fun Box.textureResolverFor(previousResolver: (Introspected.Texture) -> Pair<HasImageViewArray, TextureSampler>?): (Introspected.Texture) -> Pair<HasImageViewArray, TextureSampler> =
{ textureSpec ->
Box.context.set(this) // todo, should be a 'with'
val cached: Pair<HasImageViewArray, TextureSampler>? = previousResolver.invoke(textureSpec)
cached ?: findTextureByNameOrNull(textureSpec.name)
?: throw Exception(" can't find anything to build a texture out of from this box to ${textureSpec.name}")
}
internal fun Box.findTextureByNameOrNull(name: String): Pair<HasImageViewArray, TextureSampler>?
Field's by-name search for a shader sampler: this box's customTextureResolver, then anything
named name in the box, its properties and its parents that can be sampled
(HasImageViewArray, HasImageView, ImageView, arrays of those, LoadedTexture,
RenderToTexture, in that order). null when nothing matches.
internal fun Box.findTextureByNameOrNull(name: String): Pair<HasImageViewArray, TextureSampler>? {
lprintln("debug,graphics", "searching for texture $name")
val c = customTextureResolver
if (c != null) {
val r = c(this, name)
if (r != null) return fromViews(r, name)
}
val r = findWithNameOrNull<HasImageViewArray>(this, name)?.let { provider -> fromViews(provider, name) }
?: findWithNameOrNull<HasImageView>(this, name)?.let { hv -> fromViews(listOf(hv), name) }
?: findWithNameOrNull<ImageView>(this, name)?.let { view -> fromViews(listOf(view), name) }
?: findWithNameOrNull<Array<HasImageView>>(this, name)?.let { arr -> fromViews(arr.toList(), name) }
?: findWithNameOrNull<Array<ImageView>>(
this,
name
)?.let { arr -> fromViews(arr.map { it as HasImageView }, name) }
?: findWithNameOrNull<LoadedTexture>(this, name)?.let { tex -> fromViews(listOf(tex), name) }
?: findWithNameOrNull<RenderToTexture>(this, name)?.let { rtt -> fromViews(listOf(rtt), name) }
lprintln("debug,graphics", "found $r")
return r
}
Compute macro + module creation
fun Box.macro_computeModuleHere(args: List<String>): Insert
Macro expansion for computeModuleHere(…): install the reload and update actions, seed a trivial
outData[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240) = inData[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240) + 1 kernel if the box has no compute source, compile it, and return the
Insert carrying the generated ComputeMesh bindings and the rewritten _computeModuleHere call.
As with the graphics macro, compilation happens now, at expansion time, because the reflected interface is what the generated accessors are built from. If the shader does not compile the Insert still rewrites the call site but carries no bindings, so the shader error is what surfaces rather than a wall of unresolved Kotlin references.
@OptIn(DelicateCoroutinesApi::class)
fun Box.macro_computeModuleHere(args: List<String>): Insert = macro_computeModuleHereWithDefaultShader(args)
fun Box.macro_computeModuleHereWithDefaultShader
Seed a specialised compute shader while retaining normal Field reflection, linking and reload actions.
body — 49 lines, from line 5517
@OptIn(DelicateCoroutinesApi::class)
fun Box.macro_computeModuleHereWithDefaultShader(
args: List<String>, compute: String? = null, resolvesToCall: String = "_computeModuleHere"
): Insert {
installComputeShaderReloadAction()
installComputeShaderUpdateAction()
if (!hasShaderSource(this, "compute")) {
this.setPropertyRaw(
"compute", compute ?: """
#version 450
layout(local_size_x = 64) in;
layout(binding = 0) buffer InputBuffer {
uint inData[];
};
layout(binding = 1) buffer OutputBuffer {
uint outData[];
};
void main() {
uint idx = gl_GlobalInvocationID.x;
outData[idx] = inData[idx] + 1;
}
""".trimIndent()
)
}
clearShaderErrorsForBaseProperties(this, "compute")
val computeStage = prepareShaderStage(this, ShaderKind.COMPUTE_SHADER, "compute")
val ci = computeStage?.let {
assignPreparedShaderStage(this, it)
compileStage(this, it).introspection
}
if (ci == null) {
return Insert(here = "$resolvesToCall${args[0]}")
} else {
clearShaderErrorsForBaseProperties(this, "compute")
val insert = buildComputeInsert(ci, this)
return insert.copy(here = run {
var here = "$resolvesToCall${args[0]}"
if (args.size == 2) here += args[1]
here
})
}
}
fun Box._computeModuleHere(p: Parameters.() -> Unit): ComputeModulePointer {
The runtime compute-module builder for the trailing-lambda call site, computeModuleHere { … } after
expansion. Resolves the Renderer from the ambient context, so the box must be a descendant of its
renderer box.
fun Box._computeModuleHere(p: Parameters.() -> Unit): ComputeModulePointer {
return _computeModuleHere(parameters(renderer = fromContext(), p))
}
fun Box._computeModuleHere(p: Parameters): ComputeModulePointer
Build this box's compute module and store it as box._computeShader.
Takes its GLSL from the prepared _compute property, overriding whatever the caller set, and throws a
NullPointerException if no prepare pass has run. Wires up the same by-name texture resolver, include
resolver, source name and box-reporting error handler as the graphics path, and attaches the box's field
probe / field stats buffers. Unlike Box._moduleHere it leaves globalAsyncDebugHandler alone.
fun Box._computeModuleHere(p: Parameters): ComputeModulePointer {
p.compute = this._compute!!
val computeSourceProperty = sourcePropertyForStage(this, "compute")
p.computeSourceName = boxShaderSourceName(this, computeSourceProperty)
p.computeIncludeResolver = shaderIncludeResolverFor(this, computeSourceProperty)
p.textureResolver = textureResolverFor(p.textureResolver)
p.errorHandler = { message, throwable ->
this.displayError(message, "code", numberFromErrorMessage(message) ?: 0)
throwable?.let { lprintln("error,graphics", it.stackTraceToString()) }
}
if (p.name == "unnamed module") p.name = "module in box '${name}' (${id})"
attachFieldProbe(this, p)
val pp = ComputeModule(p).pointer()
this._computeShader = pp
registerStackAutoShaderOwner(this, pp)
return pp
}
Mesh macro + module creation
fun Box.macro_meshModuleHere(args: List<String>): Insert
Macro expansion for meshModuleHere(…): install the reload action, seed a runnable task/mesh/fragment
trio for any of the three the box is missing, compile all three in order, and return the Insert with
the generated MeshMesh bindings.
The seeded default is a real, if minimal, mesh pipeline — a task shader that emits three mesh workgroups and a mesh shader that emits one triangle each — so a fresh box draws something. Any stage failing to compile drops the bindings and rewrites the call site only.
body — 117 lines, from line 5616
@OptIn(DelicateCoroutinesApi::class)
fun Box.macro_meshModuleHere(args: List<String>): Insert {
installMeshShaderReloadAction()
if (!hasShaderSource(this, "task")) {
this.setPropertyRaw(
"task", """
#version 450
#extension GL_EXT_mesh_shader : enable
layout(local_size_x = 1, local_size_y = 1, local_size_z = 1) in;
struct PerTask { uvec3 taskGlobalID; };
taskPayloadSharedEXT PerTask perTask;
void main ()
{
uvec3 globalID = gl_WorkGroupID * gl_WorkGroupSize + gl_LocalInvocationID;
perTask.taskGlobalID = globalID;
EmitMeshTasksEXT(3,1,1);
} """.trimIndent()
)
}
if (!hasShaderSource(this, "mesh")) {
this.setPropertyRaw(
"mesh", """
#version 450
#extension GL_EXT_mesh_shader : enable
layout(local_size_x = 1, local_size_y = 1, local_size_z = 1) in;
layout (triangles) out;
layout (max_vertices=3, max_primitives=1) out;
struct PerTask { uvec3 taskGlobalID; };
taskPayloadSharedEXT PerTask perTask;
void main ()
{
SetMeshOutputsEXT(3, 1);
uvec3 globalID = gl_WorkGroupID * gl_WorkGroupSize + gl_LocalInvocationID;
uvec3 parentTaskGlobalID = perTask.taskGlobalID;
float y = globalID.x/9.0;
float x = parentTaskGlobalID.x/9.0;
gl_MeshVerticesEXT[0].gl_Position = vec4(-x,y,0,1);
gl_MeshVerticesEXT[1].gl_Position = vec4(-x+0.25,y,0,1);
gl_MeshVerticesEXT[2].gl_Position = vec4(-x+0,0.5+y,0,1);
gl_PrimitiveTriangleIndicesEXT[0] = uvec3(0,1,2);
}
""".trimIndent()
)
}
if (!hasShaderSource(this, "fragment")) {
this.setPropertyRaw(
"fragment", """
#version 450
layout(location = 0) out vec4 uFragColor;
void main()
{
uFragColor = vec4(1.0, 0.8, 0.4, 1.0);
}
""".trimIndent()
)
}
clearShaderErrorsForBaseProperties(this, "task", "mesh", "fragment")
val taskStage = prepareShaderStage(this, ShaderKind.TASK_SHADER, "task")
val meshStage = taskStage?.let {
prepareShaderStage(
this,
ShaderKind.MESH_SHADER,
"mesh",
priorStages = mapOf(ShaderKind.TASK_SHADER to it)
)
}
val fragmentStage = if (taskStage != null && meshStage != null) {
prepareShaderStage(
this,
ShaderKind.FRAGMENT_SHADER,
"fragment",
priorStages = mapOf(
ShaderKind.TASK_SHADER to taskStage,
ShaderKind.MESH_SHADER to meshStage
)
)
} else {
null
}
val ti = taskStage?.let {
assignPreparedShaderStage(this, it)
compileStage(this, it).introspection
}
val mi = meshStage?.let {
assignPreparedShaderStage(this, it)
compileStage(this, it).introspection
}
val fi = fragmentStage?.let {
assignPreparedShaderStage(this, it)
compileStage(this, it).introspection
}
if (ti == null || mi == null || fi == null) {
return Insert(here = "_meshModuleHere${args[0]}")
} else {
clearShaderErrorsForBaseProperties(this, "task", "mesh", "fragment")
val insert = buildMeshInsert(ti, mi, fi, this)
return insert.copy(here = run {
var here = "_meshModuleHere${args[0]}"
if (args.size == 2) here += args[1]
here
})
}
}
fun Box._meshModuleHere(p: Parameters.() -> Unit): MeshModulePointer {
The runtime mesh-module builder for the trailing-lambda call site. Resolves the Renderer from the
ambient context, so the box must be a descendant of its renderer box.
fun Box._meshModuleHere(p: Parameters.() -> Unit): MeshModulePointer {
return _meshModuleHere(parameters(renderer = fromContext(), p))
}
fun Box._meshModuleHere(p: Parameters = parameters {}): MeshModulePointer
Build this box's mesh-shader module from the prepared _task, _mesh and _fragment properties and
store it as box._meshShader.
All three are required and dereferenced without a null check, so a missing prepare pass is a
NullPointerException. Per-stage include resolvers and source names are installed so errors land on the
right property. Note that, unlike the graphics and compute paths, no custom texture resolver is
installed here — a mesh-pipeline shader's samplers are resolved by whatever p already carries.
fun Box._meshModuleHere(p: Parameters = parameters {}): MeshModulePointer {
p.mesh = this._mesh!!
p.task = this._task!!
p.fragment = this._fragment!!
val taskSourceProperty = sourcePropertyForStage(this, "task")
val meshSourceProperty = sourcePropertyForStage(this, "mesh")
val fragmentSourceProperty = sourcePropertyForStage(this, "fragment")
p.taskSourceName = boxShaderSourceName(this, taskSourceProperty)
p.meshSourceName = boxShaderSourceName(this, meshSourceProperty)
p.fragmentSourceName = boxShaderSourceName(this, fragmentSourceProperty)
p.taskIncludeResolver = shaderIncludeResolverFor(this, taskSourceProperty)
p.meshIncludeResolver = shaderIncludeResolverFor(this, meshSourceProperty)
p.fragmentIncludeResolver = shaderIncludeResolverFor(this, fragmentSourceProperty)
p.errorHandler = { message, throwable ->
this.displayError(message, "code", numberFromErrorMessage(message) ?: 0)
throwable?.let { lprintln("error,graphics", it.stackTraceToString()) }
}
if (p.name == "unnamed module") p.name = "mesh module in box '${name}' (${id})"
attachFieldProbe(this, p)
val pp = MeshModule(p).pointer()
this._meshShader = pp
registerStackAutoShaderOwner(this, pp)
return pp
}
Ray tracing macro + module creation
val defaultRaygenShader
The ray-generation shader a box gets when rayTracingModuleHere expands and the box has no raygen
source: it writes the launch uv into the ray payload and traces nothing at all.
Trace-free, so the pipeline builds and dispatches on any device with ray tracing without needing an acceleration structure to exist yet. Declares no push constants or bindings, so it generates no accessors either.
val defaultRaygenShader = """
#version 460
#extension GL_EXT_ray_tracing : require
layout(location = 0) rayPayloadEXT vec4 payload;
void main()
{
vec2 uv = (vec2(gl_LaunchIDEXT.xy) + vec2(0.5)) / vec2(max(gl_LaunchSizeEXT.xy, uvec2(1)));
payload = vec4(uv, 0.0, 1.0);
}
""".trimIndent()
fun Box.macro_rayTracingModuleHere(args: List<String>): Insert
Macro expansion for rayTracingModuleHere(…): install the reload action, seed defaultRaygenShader if
the box has no raygen, compile every stage the box does have, and return the Insert with the
generated RayTracingDispatch bindings.
Only raygen is seeded — the optional stages stay absent rather than being filled with placeholders, so
the expansion reflects the pipeline you actually asked for. If any present stage fails to compile the
bindings are dropped and only the call site is rewritten.
@OptIn(DelicateCoroutinesApi::class)
fun Box.macro_rayTracingModuleHere(args: List<String>): Insert {
installRayTracingShaderReloadAction()
if (!hasShaderSource(this, "raygen")) {
setPropertyRaw("raygen", defaultRaygenShader)
}
clearShaderErrorsForBaseProperties(this, "raygen", "miss", "closesthit", "anyhit", "intersection", "callable")
val stages = prepareRayTracingStages(this)
val introspections = stages?.mapNotNull {
assignPreparedShaderStage(this, it)
compileStage(this, it).introspection
}
val here = run {
var h = "_rayTracingModuleHere${args[0]}"
if (args.size == 2) h += args[1]
h
}
if (stages == null || introspections == null || introspections.size != stages.size) {
return Insert(here = here)
}
clearShaderErrorsForBaseProperties(this, "raygen", "miss", "closesthit", "anyhit", "intersection", "callable")
return buildRayTracingInsert(introspections).copy(here = here)
}
fun Box._rayTracingModuleHere(p: Parameters.() -> Unit): RayTracingModulePointer {
The runtime ray-tracing-module builder for the trailing-lambda call site. Resolves the Renderer from
the ambient context, so the box must be a descendant of its renderer box.
fun Box._rayTracingModuleHere(p: Parameters.() -> Unit): RayTracingModulePointer {
return _rayTracingModuleHere(parameters(renderer = fromContext(), p))
}
fun Box._rayTracingModuleHere(p: Parameters = parameters {}): RayTracingModulePointer
Build this box's ray-tracing module from the prepared stage properties and store it as
box._rayTracingShader.
_raygen is required and dereferenced without a check; the five optional stages become "" when absent,
and only stages the box actually has get a source name and include resolver, so a blank stage genuinely
stays out of the pipeline. Installs the by-name texture resolver and the box-reporting error handler.
Unlike the other three builders this one does not register the module for stack.auto ownership, so
{{ stack.auto.… }} slots in ray-tracing shaders have no generated setter to drive them.
body — 53 lines, from line 5857
fun Box._rayTracingModuleHere(p: Parameters = parameters {}): RayTracingModulePointer {
p.raygen = this._raygen!!
p.miss = this._miss ?: ""
p.closesthit = this._closesthit ?: ""
p.anyhit = this._anyhit ?: ""
p.intersection = this._intersection ?: ""
p.callable = this._callable ?: ""
val raygenSourceProperty = sourcePropertyForStage(this, "raygen")
p.raygenSourceName = boxShaderSourceName(this, raygenSourceProperty)
p.raygenIncludeResolver = shaderIncludeResolverFor(this, raygenSourceProperty)
fun configureOptional(base: String, assign: (String, ShaderIncludeResolver?) -> Unit) {
if (!hasShaderSource(this, base)) return
val sourceProperty = sourcePropertyForStage(this, base)
assign(boxShaderSourceName(this, sourceProperty), shaderIncludeResolverFor(this, sourceProperty))
}
configureOptional("miss") { sourceName, resolver ->
p.missSourceName = sourceName
p.missIncludeResolver = resolver
}
configureOptional("closesthit") { sourceName, resolver ->
p.closesthitSourceName = sourceName
p.closesthitIncludeResolver = resolver
}
configureOptional("anyhit") { sourceName, resolver ->
p.anyhitSourceName = sourceName
p.anyhitIncludeResolver = resolver
}
configureOptional("intersection") { sourceName, resolver ->
p.intersectionSourceName = sourceName
p.intersectionIncludeResolver = resolver
}
configureOptional("callable") { sourceName, resolver ->
p.callableSourceName = sourceName
p.callableIncludeResolver = resolver
}
p.textureResolver = textureResolverFor(p.textureResolver)
p.errorHandler = { message, throwable ->
this.displayError(message, "code", numberFromErrorMessage(message) ?: 0)
throwable?.let { lprintln("error,graphics", it.stackTraceToString()) }
}
if (p.name == "unnamed module") p.name = "ray tracing module in box '${name}' (${id})"
attachFieldProbe(this, p)
val pp = RayTracingModule(p).pointer()
this._rayTracingShader = pp
return pp
}