Editor · guide
Evaluating a texture plan
The plan of compute kernels a texture graph and a layer stack both compile to, the image pool that runs it, and the resolution rule that keeps a graph the same material at every size.
Edit this page on GitHubDocuments
- TexturePlan
- TextureOp
- TextureImage
- TextureParameter
- TextureParameterUnit
- TextureFormat
- TextureFormats
- TexturePoolSlot
- TexturePoolSchedule
- TextureKernels
- TextureKernelPrelude
- TexturePlanEvaluator
- TextureBake
- TextureProblem
- TextureProblemSeverity
- ITextureCpuOperation
- TextureCpuImage
- TextureCpuInvocation
- TextureUploads
- TextureExternal
What it is
A TexturePlan is a table of images and an ordered list of TextureOps over it. Each op names a
compute kernel, the images it reads as indices into the table, the one image it writes, and the numbers
the kernel takes. TexturePlanEvaluator runs one on an IGraphicsDevice and hands back a
TextureBake, which owns the textures and can read one out as a Bitmap or write it as a PNG.
That is the whole surface. There is no graph here and no layer stack — both of those compile to a plan, which is what stops the two from acquiring two evaluators and then two opinions about what "overlay" means.
What it is for
Computing a material's textures at author time: a blur into a levels into a blend, at 2K, written into
Assets/ as ordinary PNGs that the existing importer and the existing content build already
understand. A shipped game never evaluates one.
You do not want it for anything a frame draws. A texture graph is evaluated once, into an image; a shader graph is evaluated per pixel, per frame, on the mesh. A blur cannot exist in the second and a lighting model cannot exist in the first.
Using it
var plan = new TexturePlan { BaseWidth = 2048, BaseHeight = 2048, Seed = 41823, Images = [ new(TextureFormat.Rgba8, External: true), // 0 — supplied by the caller new(TextureFormat.Rgba16Float), // 1 — blurred along x new(TextureFormat.Rgba16Float), // 2 — and along y new(TextureFormat.Rgba8) // 3 — the output ], Ops = [ new() { Kernel = "Blur", Output = 1, Inputs = [0], Parameters = [ new("radius", 8f, TextureParameterUnit.TexelsAtBase), new("stepX", 1f), new("stepY", 0f) ] }, new() { Kernel = "Blur", Output = 2, Inputs = [1], Parameters = [ new("radius", 8f, TextureParameterUnit.TexelsAtBase), new("stepX", 0f), new("stepY", 1f) ] }, new() { Kernel = "Levels", Output = 3, Inputs = [2], Parameters = [ new("inputBlack", 0.1f), new("inputWhite", 0.9f), new("gamma", 0.8f), new("outputBlack", 0f), new("outputWhite", 1f), new("dither", 1f) ] } ], Outputs = [3]};using var evaluator = new TexturePlanEvaluator(device);using var bake = evaluator.Evaluate(plan, new Dictionary<int, TextureHandle> { [0] = source });bake.Save(3, "Assets/Materials/hull-height.png");Evaluate opens its own frame, submits one command list and waits, so it must not be called between a
caller's own BeginFrame and EndFrame. A bake is a modal operation somebody started.
Resolution is relative, and every length is in texels at the base
The plan declares the resolution the graph was authored at. Every image is a power of two away from
it — LevelOffset 1 is half, -1 is double — and only an external image has a size of its own.
Every radius, width and length is authored in texels at the base resolution and scaled by the
evaluator to the image the op writes. TextureParameterUnit.TexelsAtBase is what says so, and
TexturePlan.Resolve is the one place the scaling happens.
⚠ A radius stored as absolute texels looks right at the resolution it was tuned at and is half as wide at 4K — so a graph authored at 1K and shipped at 4K is a different material, and nobody associates the change with the resolution field. Storing it as a fraction of the image has the mirror-image failure at a non-square resolution. Texels-at-base, with the base written in the plan, is the only form in which both questions have one answer.
Baking the same graph at another resolution
BakeLevelOffset says how big the whole graph is being made this time, in the same currency and with
the same sign as an image's own level. 0 bakes at the authoring resolution, -2 bakes a 1K graph at
4K, 1 bakes a 512 preview:
var at4K = new TexturePlan { BaseWidth = 2048, // still what the graph was authored at BaseHeight = 2048, BakeLevelOffset = TexturePlan.BakeLevelFor(2048, 8192), // -2 Images = plan.Images, Ops = plan.Ops, Outputs = plan.Outputs};// Every image is four times wider, and so is every radius.var width = at4K.SizeOf(1).X; // 8192, was 2048var radius = at4K.Resolve(0, at4K.Ops[0].Find("radius")!.Value); // 32, was 8BakeLevelFor refuses a ratio that is not a power of two — a 1536-wide bake of a 1024 graph would put
every image at a size no level names. Bake at the next power of two and resample the file.
⚠
BaseWidthalone cannot express this, and until #619 it was the only field there was. Moving the base moves the unit a radius is counted in by exactly as much, so a plan with a base of 1024 and one with a base of 4096 both resolve8texels-at-base to8— the two-year fuse § D8 was written to prevent, lit inside the type meant to prevent it. Two fields; one for what the artist authored, one for what this run is producing.
⚠ One number rather than a bake width and a bake height. Two would let a caller ask for 4096×2048 out of a 1024² graph, and then a radius would be either four times wider in x and twice in y — a filter that is no longer round — or wrong in one axis.
⚠ Copying
Opslike this is right for nearly every plan and wrong for three nodes.Distance,Flood FillandAuto Levelsare chains whose op count is a function of the baked extent — a jump flood islog2(n)ping-ponged dispatches, a reduction is one per level down to 1×1 — so their ops are emitted for one resolution and re-using them at another leaves too few of them. Every op of such a chain carriesTextureOp.EmittedForExtent, andValidaterefuses the plan rather than baking a distance field that is wrong at long range and looks merely soft (#689). The fix is to re-emit the chain from the front end for the bake you want; a plan is a compiled artefact, and only the graph bakes at any size.
The image pool
An image is written by exactly one op, so it is live from that op until the last op that reads it. The
evaluator allocates on first write, frees when the last reader has run, and reuses a freed slot of the
same format and size. TexturePoolSchedule works all of that out from the op order alone, with no
device, which is what lets the bound be asserted anywhere:
var schedule = TexturePoolSchedule.For(plan);// A chain of forty ops threaded through two live images allocates two textures — at 2K, 32 MB// rather than 640 MB.Assert.Equal(2, schedule.Allocations);⚠ An op's output is taken before its dying inputs are given back. The other order hands an op the texture it is about to read, and a dispatch has no ordering between its own invocations — so what comes out is half the old image and half the new one, on some drivers, some of the time.
What a plan refuses, and what it only warns about
Check() is the whole answer and returns a TextureProblem per problem, each with a
TextureProblemSeverity. Validate() is the refusals as sentences — Evaluate throws on any of them
— and Warnings() is the other half, which a bake carries on TextureBake.Warnings:
foreach (var problem in plan.Check()) { Console.WriteLine($"{problem.Severity}: {problem.Message}");}// Warning: Op 0 runs 'Sharpen' with radius 4, which is 16 at the resolution it writes — past the 8// the kernel loops to. It would be clamped, silently, …⚠ A plan validated its shape and never its numbers, and that is #692. Indices, formats, write-once and liveness all held while a resolved radius past a kernel's own loop was clipped by the shader with no message anywhere — so the same graph was a different material at a larger bake. The number that has to be checked is the resolved one, which exists only once a bake resolution has been chosen, and the plan is the only layer that can see both it and the kernel's ceiling.
⚠ Refusing would have been wrong, which is why there is a third state. The larger bake is what the artist asked for and the clip may be acceptable; what was missing was anywhere for a bake to say it clipped something. Put
TextureBake.Warningsin front of whoever chose the resolution.
An op that is not a dispatch
TextureOp.Cpu holds an ITextureCpuOperation, and the evaluator ends the list in flight, waits,
reads its inputs back as raw texels, runs it, uploads the answer and opens a new list. The pool, the
liveness and the barriers are unchanged around it — a CPU op writes one image, reads by index, and is
written to once.
⚠ This is doc 48 § 4.6's one stated exception to § D3's "no CPU implementation of any node", and it is not an escape hatch from writing a kernel. It exists for
Normal → Height, a Poisson solve overVixen.Geometry.Uv/Solving/ConjugateGradient.cs, because there is no GPU formulation of that worth having — low frequencies converge in O(n²) Jacobi sweeps, so the shader version is thousands of dispatches. The test of whether a node belongs here is "is there a GPU formulation at all", never "would this be easier in C#": each of these costs two full pipeline drains in the middle of a bake. An implementation here that reproduces what a.rvnalready does is exactly what § D3 bans.
Formats
R8 · Rg8 · Rgba8 · R16Float · Rgba16Float. 32-bit float is deliberately absent.
⚠
R8andRg8can be read and cannot be written. Raven declares nor8orrg8storage image and Vulkan requires storage support for neither, so a kernel writing one fails at pipeline creation.TexturePlan.Validaterefuses it where the plan is built; compute in one of the three storable formats and narrow at the encode.
⚠ The 32-bit absence is a decision about material maps, not a capability. Raven admits
r32f,rg32fandrgba32f, the RHI maps all three, andHiZPyramidalready dispatches into anR32Floatstorage image — so the argument is the memory, and it is larger than it looks: anRgba16Floatintermediate at 4K is 128 MiB andRgba32Floatis 256 MiB. § 4.5's two position-carrying records are the case for widening it torgba32f(#690); a colour never is.
What a kernel is compiled against
A kernel is one Raven source, but it is not compiled alone. TextureKernelPrelude.Compile hands the
compiler the kernel and three of the shader library's own files — Core/Math.rvn,
Core/Random.rvn and Material/ComputeColor.rvn, embedded from Raven/Library at their own path —
as one compilation. So a kernel may write import Vixen.Shaders.Core and call Random.Hash, or
import Vixen.Shaders.Material and call ComputeColor.HueRotate, and get the same arithmetic the
shader graph gets from the same file.
⚠ This is why a hue matched in the shader graph does not shift in a texture graph. Five kernels used to transcribe those functions — four copies of the PCG hash, the YIQ rotation, and
Blend's overlay, hard light and soft light — under headers saying animportcould not resolve because the evaluator passed noreferencePaths. That named the wrong cause: what refused the import was the compilation holding one text, not the absence of a compiled.rvnlib. There is one copy of each function now, in the library, and a test refuses a kernel that grows a second.
⚠ A library file in the compilation is bound whether the kernel calls it or not. That is why
Math.rvnis in the set even though no kernel names it:Random.rvnspellsMath.SphericalToCartesian, and a prelude that stopped atRandom.rvnfailedRVN2010on every kernel at once. It is also why a[Permutation]in one of these three files would be a defect in every one of them — a plan has no way to name a permutation value, so every kernel would take the library's default silently. A test refuses that too.
Nothing else is reachable. A kernel cannot see a font, a glyph outline or any managed code, which is
why doc 48 § 4.1's Text and Svg Path arrive as external images rather than as kernels.
Pixels the caller supplies
An image the plan marks External: true is not allocated, not pooled and never written by an op — it
is what a bitmap input is, and the only place an absolute size enters a plan. TextureUploads is what
turns bytes into the texture behind one, and its Externals is what Evaluate takes:
using var uploads = new TextureUploads(device);uploads.Add(plan, 0, width, height, rgba); // the image's own formatuploads.AddCoverage(plan, 1, width, height, glyph.Coverage); // one float per texel, into an R8using var bake = evaluator.Evaluate(plan, uploads.Externals);It is a separate object from the bake because the two have different lifetimes: a TextureBake
destroys its textures when it is disposed, and an imported bitmap outlives every bake made from it.
Dispose it when the document closes.
⚠
R8andRg8are uploadable although no kernel can write them.TextureFormats.IsStorableanswers "may a kernel write this" and is false for both; a mask is read, costs a quarter of what RGBA costs, and a sampled read hands the kernel(r, 0, 0, 1).
⚠ Doc 48 § 4.1's
TextandSvg Patharrive this way rather than as kernels. A compute shader has no rasteriser, and a.rvncannot call managed code whatever is in its compilation — so neither can reach a font or a path parser. (⚠ Not "compiled alone with no reference paths", which this said until the section above it was written, and which was never the reason.) Both are filled on the CPU and uploaded as coverage — see #687.
What the texture behind one has to be
A bare TextureHandle says the image can be sampled, which is what every dispatch needs and all
that the overload above asks for. A TextureOp.Cpu op reads its inputs by copying out of them, so an
external image one of those reads also needs TextureUsage.CopySource — and that is declared with
TextureExternal:
var usage = TextureUsage.Sampled | TextureUsage.CopyDestination | TextureUsage.CopySource;var texture = device.CreateTexture(new(PixelFormat.Rgba8UNorm, width, height, usage));using var bake = evaluator.Evaluate(plan, new Dictionary<int, TextureExternal> { [0] = new(texture, usage) });A plan that copies out of an image declared without it is refused, naming the image and what is missing, rather than run.
⚠ This is why the declaration exists at all. A handle is an opaque number and
IGraphicsDevicecannot describe one back, so the evaluator has no other way to know. And the requirement is not theoretical: for a whole batch the CPU-op seam copied out of a caller's texture that had noTRANSFER_SRCon it —VUID-vkCmdCopyImageToBuffer-srcImage-00186— past a green device test, because MoltenVK does not enforce usage bits (#722). Build the texture and the declaration from oneTextureUsageexpression: a declaration that does not match the description is a lie nothing here can catch.
The seed
TexturePlan.Seed is hashed per op — SeedFor(op) — so a bake is reproducible and inserting a
node upstream does not change the numbers every node downstream draws. A kernel that declares a seed
uniform is given it by the evaluator; Levels uses it for the ordered dither that keeps a lifted curve
from banding in an 8-bit file.
What a bake does not do yet
Mip chains, block compression, the .vxmat write and the texturing: provenance block are the baking
phase's, over Vixen.Core.Imaging. What lands today is one image per output, as a PNG.
Testing it
TexturePlanTests, TexturePlanCheckTests, TexturePoolTests and TextureKernelTests need no device
at all — the resolution rule, what a bake clips, the pool bound and every kernel's compilation in every
format are asserted on any machine.
TexturePlanDeviceTests needs one, and names its adapter in every message.
⚠ Without
--vixen-offscreena headless run falls back to the Null device on every platform, exits 0 and prints identical healthy counters. A texture-graph test that passed there would have proved that a black image equals a black image. These skip loudly instead, andVIXEN_REQUIRE_VULKAN=1turns the skip into a failure.
What they assert are closed forms rather than goldens, and never a CPU re-implementation: a box
filter's impulse response is 1/(2r+1) over exactly 2r+1 texels; a levels curve maps three known
inputs to three known outputs; the same authored radius produces a 17-texel bar at the base resolution
and a 9-texel bar at half of it.
§ D8's own criterion is one of them —
The_same_plan_baked_at_four_times_the_resolution_agrees_with_the_smaller_bake bakes one plan at
BakeLevelOffset 0 and −2 over a step edge, box-downsamples the larger 4:1, and requires the two
profiles to agree. On an M1 Max the worst column differs by 4/255 against a tolerance of 8; a radius
that did not scale parts them by 92.
TextureCpuOpDeviceTests is the round trip through a TextureOp.Cpu op: invert → transpose →
invert, whose closed form is the transpose of the source, exactly, in every channel. ⚠ Its second
test's name claims the two pictures and not the layout barrier that hands an external image back
readable — deleting that barrier leaves both assertions green on an M1 Max, because a unified-memory
adapter reads an image left in a transfer layout perfectly well.
TextureValidationDeviceTests is the witness for the layout, and for every other barrier and usage bit
in a bake: it runs a plan with the validation layers watching and asserts they said nothing, which is
red on that same deletion (VUID-vkCmdDraw-None-09600). ⚠ It costs the suite its parallelism —
VulkanDiagnostics is process-wide, so a message is only attributable to a test when no other test is
running, and this assembly is serialised for that one reason
(#712). It skips rather than passes on a device
with no validation layer, because an instrument that reports success when it did not run is the defect
it exists to catch.
⚠
TextureQueueTestsopens a Null device on purpose, and it is the only file here that does. A unified adapter cannot tell the compute queue from the graphics one — which is why #617, a bake that wrote on one and read back on the other with no ownership transfer, was invisible on every machine this engine has been developed on.NullDevicebuilds three distinct submitters, so the question has an answer there. It asserts a queue and never a texel.
Examples
One evaluator, many bakes, and the warnings read every time. ⚠ TexturePlan is a class and not
a record, so there is no with — a plan at another size is built field by field, as in
the section above:
using var evaluator = new TexturePlanEvaluator(device);foreach (var name in outputs.Keys) { using var bake = evaluator.Evaluate(plans[name]); // The other half of Check(): what was not refused but was not obeyed either. foreach (var warning in bake.Warnings) { report($"{name}: {warning}"); } bake.Save(plans[name].Outputs[0], Path.Combine("Assets", "Materials", $"{name}.png"));}// One compiled pipeline per kernel and output format, cached across every bake above.report($"{evaluator.Dispatches} dispatches from {evaluator.Compilations} compilations");⚠ The evaluator is reused deliberately. A second one recompiles every kernel the first already built,
and Compilations is the counter that says whether the cache is doing its job — a number that grows
with the number of bakes rather than with the number of distinct kernels is the bug.
Pixels the caller made, as an image the plan reads. Anything a compute kernel cannot produce — a rasterised string, an imported bitmap, a ramp baked from a curve — enters this way:
using var uploads = new TextureUploads(device);// Image 0 is the one the plan declared `External: true`. The size is checked against the plan's.uploads.Add(plan, image: 0, width: 256, height: 256, texels);using var bake = evaluator.Evaluate(plan, uploads.Externals);See also
- The texturing plugin — the graph, the document and the panel that build a plan for a person rather than in a test.
- Baking a material — what turns these pictures into assets.
- Mesh map assets — the baked measurements a graph reads by usage.
- Shader graph previews — the assembly split this one was copied from, and why an evaluator holds a device and knows nothing about a project.